2 Commits
Author SHA1 Message Date
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
106 changed files with 8041 additions and 4979 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 # build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
# just enter the flake devShell and run cargo. # just enter the flake devShell and run cargo.
# #
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree # Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
# isn't clean under stock rustfmt; revisit on main). The release job (static # was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
# binary for D2) and the platform matrix layer on later, step by step. # issue #35). The release job (static binary for D2) and the platform matrix
# layer on later, step by step.
name: ci name: ci
on: on:
push: push:
@@ -39,6 +40,8 @@ jobs:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: fmt (check, stock defaults)
run: nix develop -c cargo fmt --check
- name: clippy (warnings denied) - name: clippy (warnings denied)
run: nix develop -c cargo clippy --all-targets -- -D warnings run: nix develop -c cargo clippy --all-targets -- -D warnings
- name: test - name: test
+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 flake for `requirements.md` **TT5** (CI runs the tiers) and the
**D1/D2/D3** distribution items (the release uses a static musl target **D1/D2/D3** distribution items (the release uses a static musl target
built through this flake). 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**.
+1 -1
View File
@@ -19,5 +19,5 @@ here too).
## Index ## 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-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-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). - [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).
+210 -188
View File
@@ -509,7 +509,10 @@ pub enum LoadPickerSubMode {
/// Switched to via `b`. Same input/cursor surface as /// Switched to via `b`. Same input/cursor surface as
/// `PathEntryModal`; kept inline so the picker can flip /// `PathEntryModal`; kept inline so the picker can flip
/// back to List with `Esc`. /// back to List with `Esc`.
PathEntry { input: String, cursor: usize }, PathEntry {
input: String,
cursor: usize,
},
} }
const PAGE_SCROLL_LINES: usize = 5; const PAGE_SCROLL_LINES: usize = 5;
@@ -697,9 +700,7 @@ impl App {
// `trimmed[1..].trim()`. // `trimmed[1..].trim()`.
let leading_ws = self.input.len() - self.input.trim_start().len(); let leading_ws = self.input.len() - self.input.trim_start().len();
let mut offset = leading_ws + 1; // past the `:` let mut offset = leading_ws + 1; // past the `:`
while offset < self.input.len() while offset < self.input.len() && self.input.as_bytes()[offset].is_ascii_whitespace() {
&& self.input.as_bytes()[offset].is_ascii_whitespace()
{
offset += 1; offset += 1;
} }
let view = &self.input[offset..]; let view = &self.input[offset..];
@@ -727,8 +728,7 @@ impl App {
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> { pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
let mode = match self.effective_mode() { let mode = match self.effective_mode() {
EffectiveMode::Simple => Mode::Simple, EffectiveMode::Simple => Mode::Simple,
EffectiveMode::AdvancedPersistent EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => Mode::Advanced,
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
}; };
// Strip the `:` one-shot prefix so the walker verdicts the SQL // Strip the `:` one-shot prefix so the walker verdicts the SQL
// itself, not the escape marker (which it can't parse). // itself, not the escape marker (which it can't parse).
@@ -1037,10 +1037,7 @@ impl App {
Vec::new() Vec::new()
} }
AppEvent::ExportSucceeded { path } => { AppEvent::ExportSucceeded { path } => {
self.note_system(crate::t!( self.note_system(crate::t!("project.export_ok", path = path.display()));
"project.export_ok",
path = path.display()
));
Vec::new() Vec::new()
} }
AppEvent::ExportFailed { error } => { AppEvent::ExportFailed { error } => {
@@ -1056,11 +1053,7 @@ impl App {
// `[ok] replay — N command(s)` summary is payload-bearing // `[ok] replay — N command(s)` summary is payload-bearing
// (the count) and stays. // (the count) and stays.
self.mark_oldest_pending_echo(EchoStatus::Ok); self.mark_oldest_pending_echo(EchoStatus::Ok);
self.note_system(crate::t!( self.note_system(crate::t!("replay.completed", path = path, count = count));
"replay.completed",
path = path,
count = count
));
// ADR-0034: surface `[skip]` warnings for app-lifecycle // ADR-0034: surface `[skip]` warnings for app-lifecycle
// commands whose omission can leave the replayed state // commands whose omission can leave the replayed state
// incomplete (`import`, nested `replay`). // incomplete (`import`, nested `replay`).
@@ -1084,11 +1077,7 @@ impl App {
// it, mirroring how the interactive `running: …` // it, mirroring how the interactive `running: …`
// path renders source-line context above an error. // path renders source-line context above an error.
if line_number == 0 { if line_number == 0 {
self.note_error(crate::t!( self.note_error(crate::t!("replay.failed_open", path = path, error = error));
"replay.failed_open",
path = path,
error = error
));
} else { } else {
self.note_error(crate::t!( self.note_error(crate::t!(
"replay.failed_at_line", "replay.failed_at_line",
@@ -1097,10 +1086,7 @@ impl App {
error = error error = error
)); ));
if !command.is_empty() { if !command.is_empty() {
self.note_error(crate::t!( self.note_error(crate::t!("replay.command_echo", command = command));
"replay.command_echo",
command = command
));
} }
} }
Vec::new() Vec::new()
@@ -1237,8 +1223,7 @@ impl App {
// stays F1-only. // stays F1-only.
let hint_key = key.code == KeyCode::F(1) let hint_key = key.code == KeyCode::F(1)
|| (self.demo_mode || (self.demo_mode
&& (key.code, key.modifiers) && (key.code, key.modifiers) == (KeyCode::Char('g'), KeyModifiers::CONTROL));
== (KeyCode::Char('g'), KeyModifiers::CONTROL));
if hint_key { if hint_key {
if self.input.trim().is_empty() { if self.input.trim().is_empty() {
self.note_hint_for_recent_error(); self.note_hint_for_recent_error();
@@ -1369,8 +1354,8 @@ impl App {
/// against crossterm 0.29). Only active in demo mode (the caller /// against crossterm 0.29). Only active in demo mode (the caller
/// gates on `self.demo_mode`). /// gates on `self.demo_mode`).
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> { fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let is_toggle = key.code == KeyCode::Char('5') let is_toggle =
&& key.modifiers.contains(KeyModifiers::CONTROL); key.code == KeyCode::Char('5') && key.modifiers.contains(KeyModifiers::CONTROL);
if self.demo_caption_capturing { if self.demo_caption_capturing {
if is_toggle { if is_toggle {
@@ -1379,8 +1364,7 @@ impl App {
self.demo_caption_capturing = false; self.demo_caption_capturing = false;
let text = std::mem::take(&mut self.demo_caption_buffer); let text = std::mem::take(&mut self.demo_caption_buffer);
let trimmed = text.trim(); let trimmed = text.trim();
self.demo_caption = self.demo_caption = (!trimmed.is_empty()).then(|| trimmed.to_string());
(!trimmed.is_empty()).then(|| trimmed.to_string());
} else { } else {
match key.code { match key.code {
// Plain characters accumulate invisibly; the prompt // Plain characters accumulate invisibly; the prompt
@@ -1553,7 +1537,10 @@ impl App {
&self.schema_cache, &self.schema_cache,
self.effective_mode().as_mode(), 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) Some(comp)
} }
@@ -1581,8 +1568,7 @@ impl App {
idx: usize, idx: usize,
) -> crate::completion::LastCompletion { ) -> crate::completion::LastCompletion {
let inserted = comp.candidates[idx].text.clone(); let inserted = comp.candidates[idx].text.clone();
let original_text = let original_text = self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
self.input self.input
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted); .replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
let new_end = comp.replaced_range.0 + inserted.len(); let new_end = comp.replaced_range.0 + inserted.len();
@@ -1777,7 +1763,10 @@ impl App {
// teaching echo (ADR-0038) on an advanced effective mode. // teaching echo (ADR-0038) on an advanced effective mode.
let (submission_mode, effective_input) = let (submission_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') { 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 { } else if self.mode == Mode::Advanced {
(EffectiveMode::AdvancedPersistent, trimmed.to_string()) (EffectiveMode::AdvancedPersistent, trimmed.to_string())
} else { } else {
@@ -1845,11 +1834,7 @@ impl App {
/// simple and advanced modes; the parse-first refactor /// simple and advanced modes; the parse-first refactor
/// (round-5) routes app commands here before the /// (round-5) routes app commands here before the
/// mode-specific DSL/SQL paths. /// mode-specific DSL/SQL paths.
fn dispatch_app_command( fn dispatch_app_command(&mut self, cmd: crate::dsl::AppCommand, source: &str) -> Vec<Action> {
&mut self,
cmd: crate::dsl::AppCommand,
source: &str,
) -> Vec<Action> {
use crate::dsl::{AppCommand, MessagesValue, ModeValue}; use crate::dsl::{AppCommand, MessagesValue, ModeValue};
debug!(command = ?cmd, "dispatch app command"); debug!(command = ?cmd, "dispatch app command");
match cmd { match cmd {
@@ -2009,11 +1994,8 @@ impl App {
// mode so the walker gates SQL-only forms — simple-mode // mode so the walker gates SQL-only forms — simple-mode
// `select` returns the "this is SQL" hint as a normal // `select` returns the "this is SQL" hint as a normal
// parse error and is rendered through the Err arm below. // parse error and is rendered through the Err arm below.
match crate::dsl::parser::parse_command_with_schema_in_mode( match crate::dsl::parser::parse_command_with_schema_in_mode(input, &self.schema_cache, mode)
input, {
&self.schema_cache,
mode,
) {
Ok(Command::Replay { path }) => { Ok(Command::Replay { path }) => {
// `replay` is parsed as a DSL command for the // `replay` is parsed as a DSL command for the
// sake of grammar uniformity, but its execution // sake of grammar uniformity, but its execution
@@ -2127,15 +2109,9 @@ impl App {
.get(..*position) .get(..*position)
.map_or(*position, |s| s.chars().count()); .map_or(*position, |s| s.chars().count());
let pad = prefix.chars().count() + chars_before; let pad = prefix.chars().count() + chars_before;
self.note_error(crate::t!( self.note_error(crate::t!("parse.caret", padding = " ".repeat(pad)));
"parse.caret",
padding = " ".repeat(pad)
));
} }
self.note_error(crate::t!( self.note_error(crate::t!("parse.error", detail = parse_error_message(&err)));
"parse.error",
detail = parse_error_message(&err)
));
// ADR-0033 Amendment 3: combine the DSL error with a // ADR-0033 Amendment 3: combine the DSL error with a
// pointer to advanced mode when the same line would // pointer to advanced mode when the same line would
// run as SQL there. Only in simple mode (a one-shot // run as SQL there. Only in simple mode (a one-shot
@@ -2228,7 +2204,11 @@ impl App {
| Command::AddRelationship { .. } | Command::AddRelationship { .. }
| Command::DropRelationship { .. } | 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( for line in crate::output_render::render_structure_with_diagrams(
desc, desc,
self.last_output_width, self.last_output_width,
@@ -2252,11 +2232,7 @@ impl App {
} }
} }
fn handle_dsl_explain_success( fn handle_dsl_explain_success(&mut self, command: &Command, plan: &crate::db::QueryPlan) {
&mut self,
command: &Command,
plan: &crate::db::QueryPlan,
) {
self.note_ok_summary(command); self.note_ok_summary(command);
// ADR-0028 §3: the display SQL, then the plan tree. // ADR-0028 §3: the display SQL, then the plan tree.
// `render_explain_plan` returns ready-built `OutputLine`s // `render_explain_plan` returns ready-built `OutputLine`s
@@ -2350,11 +2326,7 @@ impl App {
} }
} }
fn handle_dsl_add_column_success( fn handle_dsl_add_column_success(&mut self, command: &Command, result: AddColumnResult) {
&mut self,
command: &Command,
result: AddColumnResult,
) {
self.note_ok_summary(command); self.note_ok_summary(command);
// ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s) // ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s)
// before the structure render so the pedagogical "the tool did // before the structure render so the pedagogical "the tool did
@@ -2369,19 +2341,12 @@ impl App {
self.current_table = Some(result.description); self.current_table = Some(result.description);
} }
fn handle_dsl_drop_column_success( fn handle_dsl_drop_column_success(&mut self, command: &Command, result: DropColumnResult) {
&mut self,
command: &Command,
result: DropColumnResult,
) {
self.note_ok_summary(command); self.note_ok_summary(command);
// ADR-0025: when `--cascade` removed covering indexes, // ADR-0025: when `--cascade` removed covering indexes,
// name each one so the learner sees the side effect. // name each one so the learner sees the side effect.
for index in &result.dropped_indexes { for index in &result.dropped_indexes {
self.note_system(crate::t!( self.note_system(crate::t!("ok.index_dropped_with_column", index = index,));
"ok.index_dropped_with_column",
index = index,
));
} }
for line in crate::output_render::render_structure(&result.description) { for line in crate::output_render::render_structure(&result.description) {
self.note_system(line); self.note_system(line);
@@ -2415,10 +2380,7 @@ impl App {
lossy = note.lossy lossy = note.lossy
) )
} else { } else {
crate::t!( crate::t!("client_side.transformed", count = note.transformed)
"client_side.transformed",
count = note.transformed
)
}; };
self.push_category_three_prose(line); self.push_category_three_prose(line);
} }
@@ -2583,9 +2545,7 @@ impl App {
(Operation::RenameTable, Some(table.as_str()), None) (Operation::RenameTable, Some(table.as_str()), None)
} }
}, },
C::SqlCreateTable { name, .. } => { C::SqlCreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
(Operation::CreateTable, Some(name.as_str()), None)
}
C::DropTable { name } => (Operation::DropTable, 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::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None),
C::AddColumn { table, column, .. } => ( C::AddColumn { table, column, .. } => (
@@ -2635,9 +2595,7 @@ impl App {
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation // SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
// (it reuses `do_add_index`); route engine/validation errors // (it reuses `do_add_index`); route engine/validation errors
// through it with the parsed table. // through it with the parsed table.
C::SqlCreateIndex { table, .. } => { C::SqlCreateIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
(Operation::AddIndex, Some(table.as_str()), None)
}
C::AddConstraint { table, column, .. } => ( C::AddConstraint { table, column, .. } => (
Operation::AddConstraint, Operation::AddConstraint,
Some(table.as_str()), Some(table.as_str()),
@@ -2700,19 +2658,13 @@ impl App {
// `dispatch_input` routes them through // `dispatch_input` routes them through
// `dispatch_app_command` before the DSL execution // `dispatch_app_command` before the DSL execution
// pipeline that this context builder feeds. // pipeline that this context builder feeds.
C::App(_) => unreachable!( C::App(_) => unreachable!("App commands are dispatched before reaching dsl execution"),
"App commands are dispatched before reaching dsl execution"
),
}; };
TranslateContext { TranslateContext {
operation: Some(operation), operation: Some(operation),
table: facts table: facts.table.or_else(|| fallback_table.map(str::to_string)),
.table column: facts.column.or_else(|| fallback_column.map(str::to_string)),
.or_else(|| fallback_table.map(str::to_string)),
column: facts
.column
.or_else(|| fallback_column.map(str::to_string)),
child_table: facts.child_table, child_table: facts.child_table,
parent_table: facts.parent_table, parent_table: facts.parent_table,
parent_column: facts.parent_column, parent_column: facts.parent_column,
@@ -2818,11 +2770,7 @@ impl App {
} }
} }
fn handle_path_entry_key( fn handle_path_entry_key(&mut self, key: KeyEvent, mut state: PathEntryModal) -> Vec<Action> {
&mut self,
key: KeyEvent,
mut state: PathEntryModal,
) -> Vec<Action> {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
self.modal = None; self.modal = None;
@@ -2904,11 +2852,7 @@ impl App {
} }
} }
fn handle_load_picker_key( fn handle_load_picker_key(&mut self, key: KeyEvent, mut state: LoadPickerModal) -> Vec<Action> {
&mut self,
key: KeyEvent,
mut state: LoadPickerModal,
) -> Vec<Action> {
match &mut state.sub_mode { match &mut state.sub_mode {
LoadPickerSubMode::List => match key.code { LoadPickerSubMode::List => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
@@ -3198,7 +3142,10 @@ impl App {
.map(|c| c.text.clone()) .map(|c| c.text.clone())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .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(), None => self.note_getting_started(),
} }
@@ -3413,10 +3360,7 @@ fn render_usage_block(input: &str, mode: Mode) -> String {
.into_iter() .into_iter()
.map(|w| format!("`{w}`")) .map(|w| format!("`{w}`"))
.collect(); .collect();
crate::t!( crate::t!("parse.available_commands", commands = names.join(", "))
"parse.available_commands",
commands = names.join(", ")
)
} }
fn render_cascade_effect(effect: &CascadeEffect) -> String { fn render_cascade_effect(effect: &CascadeEffect) -> String {
@@ -3424,9 +3368,7 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String {
let action_key = match effect.action { let action_key = match effect.action {
ReferentialAction::Cascade => "db.cascade.action_deleted", ReferentialAction::Cascade => "db.cascade.action_deleted",
ReferentialAction::SetNull => "db.cascade.action_set_null", ReferentialAction::SetNull => "db.cascade.action_set_null",
ReferentialAction::Restrict | ReferentialAction::NoAction => { ReferentialAction::Restrict | ReferentialAction::NoAction => "db.cascade.action_blocked",
"db.cascade.action_blocked"
}
}; };
crate::t!( crate::t!(
"db.cascade.summary", "db.cascade.summary",
@@ -3464,7 +3406,10 @@ mod tests {
fn demo_badge_label_maps_the_invisible_keys() { fn demo_badge_label_maps_the_invisible_keys() {
let none = KeyModifiers::NONE; let none = KeyModifiers::NONE;
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]")); 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::Enter, none)), Some("[ENTER]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]")); assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]")); assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
@@ -3474,8 +3419,14 @@ mod tests {
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]")); 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::End, none)), Some("[END]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]")); assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]")); assert_eq!(
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]")); 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::Delete, none)), Some("[DEL]"));
assert_eq!( assert_eq!(
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)), demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
@@ -3493,12 +3444,24 @@ mod tests {
#[test] #[test]
fn demo_badge_label_none_for_glyphs_and_excluded_chords() { fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
// Plain characters render their own glyph — no badge. // Plain characters render their own glyph — no badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None); assert_eq!(
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None); 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. // 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. // 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] #[test]
@@ -3606,7 +3569,10 @@ mod tests {
assert!(app.demo_caption_capturing, "still capturing"); assert!(app.demo_caption_capturing, "still capturing");
assert_eq!(app.demo_caption_buffer, "note"); assert_eq!(app.demo_caption_buffer, "note");
assert_eq!(app.input, ""); 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] #[test]
@@ -4211,7 +4177,9 @@ mod tests {
type_str(&mut app, "copy sideways"); type_str(&mut app, "copy sideways");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!( assert!(
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))), !actions
.iter()
.any(|a| matches!(a, Action::CopyToClipboard(_))),
"an unknown target does not copy", "an unknown target does not copy",
); );
let rendered = app let rendered = app
@@ -4399,7 +4367,10 @@ mod tests {
); );
// … names the table's columns so the user can see what's needed … // … names the table's columns so the user can see what's needed …
assert!( 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}", "missing the column-name list in: {out}",
); );
// … and shows the column-list override targeting the non-auto columns. // … and shows the column-list override targeting the non-auto columns.
@@ -4423,9 +4394,15 @@ mod tests {
let _ = submit(&mut app); let _ = submit(&mut app);
let out = error_lines(&app); let out = error_lines(&app);
// The teaching line names the user-supplied columns … // 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 … // … 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 … // … signals the contract …
assert!( assert!(
out.contains("auto-generated"), out.contains("auto-generated"),
@@ -4520,10 +4497,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
install_customers_schema_two_serials(&mut app); install_customers_schema_two_serials(&mut app);
app.mode = Mode::Advanced; app.mode = Mode::Advanced;
type_str( type_str(&mut app, "insert into Customers values (13, 'Oli', 42, 13)");
&mut app,
"insert into Customers values (13, 'Oli', 42, 13)",
);
let actions = submit(&mut app); let actions = submit(&mut app);
assert!( assert!(
actions actions
@@ -4552,7 +4526,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)"); type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!( 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:?}", "simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
); );
} }
@@ -4565,7 +4541,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli')"); type_str(&mut app, "insert into Customers values ('Oli')");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!( 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:?}", "simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
); );
} }
@@ -4580,7 +4558,9 @@ mod tests {
type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')"); type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!( 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:?}", "simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
); );
} }
@@ -4616,9 +4596,7 @@ mod tests {
for c in &tc { for c in &tc {
app.schema_cache.columns.push(c.name.clone()); app.schema_cache.columns.push(c.name.clone());
} }
app.schema_cache app.schema_cache.table_columns.insert("T".to_string(), tc);
.table_columns
.insert("T".to_string(), tc);
type_str(&mut app, "insert into T values (1, 2), (3, 4)"); type_str(&mut app, "insert into T values (1, 2), (3, 4)");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!( assert!(
@@ -4649,11 +4627,11 @@ mod tests {
// advanced-mode hint at all, so we look for any line carrying // advanced-mode hint at all, so we look for any line carrying
// the "mode advanced" actionable fragment that the pointer // the "mode advanced" actionable fragment that the pointer
// always emits. // always emits.
let has_pointer = app let has_pointer = app.output.iter().any(|l| l.text.contains("mode advanced"));
.output assert!(
.iter() !has_pointer,
.any(|l| l.text.contains("mode advanced")); "unknown command must not point at advanced mode"
assert!(!has_pointer, "unknown command must not point at advanced mode"); );
} }
#[test] #[test]
@@ -4702,7 +4680,11 @@ mod tests {
app.mode = mode; app.mode = mode;
type_str(&mut app, input); type_str(&mut app, input);
match submit(&mut app).as_slice() { 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:?}"), other => panic!("expected one ExecuteDsl; got {other:?}"),
} }
}; };
@@ -4733,7 +4715,9 @@ mod tests {
app.update(AppEvent::DslSucceeded { app.update(AppEvent::DslSucceeded {
command: cmd.clone(), command: cmd.clone(),
description: None, 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(); let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
// ADR-0040: no `[ok]` summary; with no preceding `running:` echo // ADR-0040: no `[ok]` summary; with no preceding `running:` echo
@@ -4792,7 +4776,10 @@ mod tests {
.position(|t| t.contains("Executing SQL:")) .position(|t| t.contains("Executing SQL:"))
.expect("an echo line"); .expect("an echo line");
assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}"); 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 // ADR-0038 §4 polish: every success arm now wires the echo as
// `OutputKind::TeachingEcho` so `ui::render_output_line` fires // `OutputKind::TeachingEcho` so `ui::render_output_line` fires
// the dim-prefix + advanced-lex custom branch. Pinning this // the dim-prefix + advanced-lex custom branch. Pinning this
@@ -4911,7 +4898,9 @@ mod tests {
description: sample_description("T"), description: sample_description("T"),
client_side: None, 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, dont_convert_caveat: false,
}); });
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text"); assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
@@ -5126,9 +5115,7 @@ mod tests {
dont_convert_caveat: false, dont_convert_caveat: false,
}); });
assert!( assert!(
!app.output !app.output.iter().any(|l| l.text.contains("--dont-convert")),
.iter()
.any(|l| l.text.contains("--dont-convert")),
"no caveat in simple mode (no echo to refer to)", "no caveat in simple mode (no echo to refer to)",
); );
} }
@@ -5198,7 +5185,10 @@ mod tests {
); );
// Pin the `Executing SQL:` prefix repeats once per statement // Pin the `Executing SQL:` prefix repeats once per statement
// (the plain-rendering shape until the styled-runs polish lands). // (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:?}"); assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
} }
@@ -5223,19 +5213,13 @@ mod tests {
// (could be the caret line, the parse-error detail // (could be the caret line, the parse-error detail
// line, or the usage line). Scan for the friendly // line, or the usage line). Scan for the friendly
// "unknown mode" anchor phrase. // "unknown mode" anchor phrase.
let anywhere = app let anywhere = app.output.iter().any(|l| l.text.contains("unknown mode"));
.output
.iter()
.any(|l| l.text.contains("unknown mode"));
assert!( assert!(
anywhere, anywhere,
"expected 'unknown mode' somewhere in output: {:?}", "expected 'unknown mode' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(), app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
); );
let any_error = app let any_error = app.output.iter().any(|l| l.kind == OutputKind::Error);
.output
.iter()
.any(|l| l.kind == OutputKind::Error);
assert!(any_error, "expected at least one Error line"); assert!(any_error, "expected at least one Error line");
} }
@@ -5325,11 +5309,17 @@ mod tests {
app.schema_cache.tables = vec!["Orders".into(), "Customers".into()]; app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
app.schema_cache.table_columns.insert( app.schema_cache.table_columns.insert(
"Orders".into(), "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( app.schema_cache.table_columns.insert(
"Customers".into(), "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 t in app.schema_cache.tables.clone() {
for c in &app.schema_cache.table_columns[&t] { for c in &app.schema_cache.table_columns[&t] {
@@ -5490,10 +5480,7 @@ mod tests {
detail: "SCAN Customers".to_string(), detail: "SCAN Customers".to_string(),
}], }],
}; };
app.update(AppEvent::DslExplainSucceeded { app.update(AppEvent::DslExplainSucceeded { command: cmd, plan });
command: cmd,
plan,
});
// ADR-0040: no `[ok] explain …` header — the (no-echo here) // ADR-0040: no `[ok] explain …` header — the (no-echo here)
// command's success shows via the marker; the plan output // command's success shows via the marker; the plan output
// itself carries the content. // itself carries the content.
@@ -5549,7 +5536,11 @@ mod tests {
.iter() .iter()
.find(|l| l.kind == OutputKind::Echo) .find(|l| l.kind == OutputKind::Echo)
.expect("dispatch pushed an 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 { app.update(AppEvent::DslSucceeded {
command: Command::CreateTable { command: Command::CreateTable {
name: "T".to_string(), name: "T".to_string(),
@@ -5639,8 +5630,14 @@ mod tests {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
assert!(text.contains("[ok] replay"), "summary present:\n{text}"); assert!(text.contains("[ok] replay"), "summary present:\n{text}");
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}"); assert!(
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}"); 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] #[test]
@@ -5736,7 +5733,7 @@ mod tests {
#[test] #[test]
fn hint_command_parses_to_app_hint() { fn hint_command_parses_to_app_hint() {
use crate::dsl::{parse_command, AppCommand, Command}; use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!( assert!(matches!(
parse_command("hint"), parse_command("hint"),
Ok(Command::App(AppCommand::Hint)) Ok(Command::App(AppCommand::Hint))
@@ -5747,7 +5744,7 @@ mod tests {
#[test] #[test]
fn version_command_parses_to_app_version() { fn version_command_parses_to_app_version() {
use crate::dsl::{parse_command, AppCommand, Command}; use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!( assert!(matches!(
parse_command("version"), parse_command("version"),
Ok(Command::App(AppCommand::Version)) Ok(Command::App(AppCommand::Version))
@@ -5801,7 +5798,10 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
type_str(&mut app, "show "); type_str(&mut app, "show ");
app.update(key(KeyCode::Tab)); 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(); let input = app.input.clone();
f1(&mut app); f1(&mut app);
assert!(app.last_completion.is_some(), "F1 must not clear the memo"); assert!(app.last_completion.is_some(), "F1 must not clear the memo");
@@ -5827,8 +5827,14 @@ mod tests {
let input = app.input.clone(); let input = app.input.clone();
let before = app.output.len(); let before = app.output.len();
app.update(ctrl_g()); app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not change the buffer (no `g` typed)"); assert_eq!(
assert!(app.output.len() > before, "Ctrl-G must emit the same hint F1 does"); 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] #[test]
@@ -5855,7 +5861,11 @@ mod tests {
let before = app.output.len(); let before = app.output.len();
app.update(ctrl_g()); app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not insert a `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] #[test]
@@ -5949,7 +5959,10 @@ mod tests {
#[test] #[test]
fn f1_on_add_relationship_renders_the_relationship_block() { fn f1_on_add_relationship_renders_the_relationship_block() {
let mut app = App::new(); 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); f1(&mut app);
assert!( assert!(
output_contains(&app, "one parent, many children"), output_contains(&app, "one parent, many children"),
@@ -6142,14 +6155,8 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
let cmd = Command::Update { let cmd = Command::Update {
table: "Customers".to_string(), table: "Customers".to_string(),
assignments: vec![( assignments: vec![("id".to_string(), crate::dsl::Value::Number("7".to_string()))],
"id".to_string(), filter: crate::dsl::RowFilter::eq("name", crate::dsl::Value::Text("Bob".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 { let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(), message: "UNIQUE constraint failed: Customers.id".to_string(),
@@ -6709,7 +6716,10 @@ mod tests {
app.update(key(KeyCode::Backspace)); app.update(key(KeyCode::Backspace));
let actions = app.update(key(KeyCode::Enter)); let actions = app.update(key(KeyCode::Enter));
assert_eq!(app.input, "select", "input untouched in navigation mode"); 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] #[test]
@@ -6720,7 +6730,10 @@ mod tests {
app.update(key(KeyCode::Down)); app.update(key(KeyCode::Down));
app.update(key(KeyCode::Down)); app.update(key(KeyCode::Down));
assert_eq!(app.tables_scroll, 2); 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)); app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 1); assert_eq!(app.tables_scroll, 1);
// Up saturates at the top. // Up saturates at the top.
@@ -6998,7 +7011,8 @@ mod tests {
for round in 0..3 { for round in 0..3 {
app.update(key(KeyCode::Up)); app.update(key(KeyCode::Up));
assert_eq!( assert_eq!(
app.input, "insert into Thing values (1)", app.input,
"insert into Thing values (1)",
"Up #{} should recall the newest entry", "Up #{} should recall the newest entry",
round + 1, round + 1,
); );
@@ -7220,8 +7234,7 @@ mod tests {
has_default: false, has_default: false,
}], }],
); );
app.input = app.input = "select * from products where price like 5".to_string();
"select * from products where price like 5".to_string();
assert_eq!( assert_eq!(
app.input_validity_verdict(), app.input_validity_verdict(),
Some(crate::dsl::walker::Severity::Warning), Some(crate::dsl::walker::Severity::Warning),
@@ -7350,8 +7363,10 @@ mod tests {
"directly-deleted count surfaced: {texts:?}", "directly-deleted count surfaced: {texts:?}",
); );
assert!( assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`") texts
&& t.contains("relationship `places`")), .iter()
.any(|t| t.contains("2 row(s) deleted in `Orders`")
&& t.contains("relationship `places`")),
"per-relationship cascade summary surfaced: {texts:?}", "per-relationship cascade summary surfaced: {texts:?}",
); );
} }
@@ -7386,11 +7401,15 @@ mod tests {
}); });
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect(); let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!( 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:?}", "seeded-row count surfaced: {texts:?}",
); );
assert!( 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:?}", "the advisory names the enum-ish column: {texts:?}",
); );
} }
@@ -7424,8 +7443,9 @@ mod tests {
}); });
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect(); let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!( assert!(
texts.iter().any(|t| t.contains("4 row(s) seeded into J") texts
&& t.contains("of 10 requested")), .iter()
.any(|t| t.contains("4 row(s) seeded into J") && t.contains("of 10 requested")),
"the cap note surfaces requested vs produced: {texts:?}", "the cap note surfaces requested vs produced: {texts:?}",
); );
} }
@@ -7464,7 +7484,9 @@ mod tests {
}); });
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect(); let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!( 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:?}", "cascade summary still surfaces alongside RETURNING: {texts:?}",
); );
assert!( assert!(
+37 -37
View File
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
use tracing::{debug, info}; use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions}; use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{ use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
/// File names excluded from `export` zips. These are either /// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`), /// derived (`playground.db`), per-process (`.lock`),
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
limit = format_args!("{limit:02}"), limit = format_args!("{limit:02}"),
)) ))
} }
Self::InvalidZip(detail) => f.write_str(&crate::t!( Self::InvalidZip(detail) => {
"archive.invalid_zip", f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
detail = detail,
)),
Self::NotAProjectArchive => {
f.write_str(&crate::t!("archive.not_a_project_archive"))
} }
Self::MultipleTopFolders => { Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
f.write_str(&crate::t!("archive.multiple_top_folders")) 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); .unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?; add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive( add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
writer.finish().map_err(|e| ArchiveError::Zip { writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(), path: dst_zip.to_path_buf(),
@@ -392,10 +378,7 @@ pub struct ZipInspection {
/// ///
/// Returns the resolved target path and the suffix that was /// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise). /// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target( pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name); let direct = parent.join(name);
if !direct.exists() { if !direct.exists() {
return Ok((direct, 0)); return Ok((direct, 0));
@@ -495,10 +478,12 @@ pub fn extract_into(
source, source,
})?; })?;
let mut buf = Vec::with_capacity(entry.size() as usize); let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io { entry
path: dst_path.clone(), .read_to_end(&mut buf)
source, .map_err(|source| ArchiveError::Io {
})?; path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io { out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(), path: dst_path.clone(),
source, 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::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::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").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(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap(); fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded. // Stray atomic-write staging file — must be excluded.
@@ -536,7 +525,9 @@ mod tests {
) )
.unwrap(); .unwrap();
fs::write( 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], [0u8; 16],
) )
.unwrap(); .unwrap();
@@ -618,11 +609,15 @@ mod tests {
let zip_path = tmp.path().join("notaproject.zip"); let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap(); let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f); 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.write_all(b"hi").unwrap();
w.finish().unwrap(); w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse"); 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] #[test]
@@ -631,13 +626,18 @@ mod tests {
let zip_path = tmp.path().join("multi.zip"); let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap(); let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f); 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.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.write_all(b"x").unwrap();
w.finish().unwrap(); w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse"); 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] #[test]
+44 -24
View File
@@ -96,10 +96,7 @@ pub enum ArgsError {
impl std::fmt::Display for ArgsError { impl std::fmt::Display for ArgsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::MissingValue(flag) => f.write_str(&crate::t!( Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
"cli.missing_value",
flag = flag,
)),
Self::InvalidValue { Self::InvalidValue {
flag, flag,
value, value,
@@ -110,10 +107,7 @@ impl std::fmt::Display for ArgsError {
value = value, value = value,
expected = expected, expected = expected,
)), )),
Self::Unknown(arg) => f.write_str(&crate::t!( Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
"cli.unknown_argument",
arg = arg,
)),
Self::MultiplePaths { first, second } => f.write_str(&crate::t!( Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
"cli.multiple_paths", "cli.multiple_paths",
first = first, first = first,
@@ -261,7 +255,11 @@ fn default_theme() -> Theme {
// Standard convention: 0..=6 and 8 are dark backgrounds, // Standard convention: 0..=6 and 8 are dark backgrounds,
// 7 and 9..=15 are light. ITerm emits 15 for white-ish. // 7 and 9..=15 are light. ITerm emits 15 for white-ish.
let is_dark = matches!(code, 0..=6 | 8); 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() Theme::default()
} }
@@ -314,10 +312,19 @@ mod tests {
#[test] #[test]
fn mode_flag_simple_and_advanced() { fn mode_flag_simple_and_advanced() {
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple)); assert_eq!(
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced)); 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. // 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] #[test]
@@ -350,7 +357,10 @@ mod tests {
#[test] #[test]
fn data_dir_flag_parses() { fn data_dir_flag_parses() {
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap(); 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] #[test]
@@ -370,13 +380,11 @@ mod tests {
#[test] #[test]
fn data_dir_and_positional_can_coexist() { fn data_dir_and_positional_can_coexist() {
let args = Args::parse([ let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
"--data-dir", assert_eq!(
"/tmp/data", args.data_dir.as_deref(),
"/home/me/MyProject", Some(std::path::Path::new("/tmp/data"))
]) );
.unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
assert_eq!( assert_eq!(
args.project_path.as_deref(), args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject")) Some(std::path::Path::new("/home/me/MyProject"))
@@ -386,7 +394,10 @@ mod tests {
#[test] #[test]
fn two_positional_paths_error() { fn two_positional_paths_error() {
let err = Args::parse(["/a", "/b"]).unwrap_err(); let err = Args::parse(["/a", "/b"]).unwrap_err();
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}"); assert!(
matches!(err, ArgsError::MultiplePaths { .. }),
"got: {err:?}"
);
} }
#[test] #[test]
@@ -455,7 +466,10 @@ mod tests {
// Absent `--demo` (and absent env var in the test runner), // Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users. // demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap(); 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] #[test]
@@ -484,7 +498,10 @@ mod tests {
} }
// Disabling values. // Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] { 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"
);
} }
} }
@@ -493,7 +510,10 @@ mod tests {
// Make sure the path-vs-flag distinction is robust: // Make sure the path-vs-flag distinction is robust:
// unknown flags don't get silently swallowed as paths. // unknown flags don't get silently swallowed as paths.
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err(); 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 ---- // ---- ADR-0054: --version / -V ----
+254 -210
View File
@@ -15,10 +15,10 @@
//! `app.rs`; this module owns the candidate computation. //! `app.rs`; this module owns the candidate computation.
use crate::dsl::grammar::IdentSource; use crate::dsl::grammar::IdentSource;
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation; use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command}; use crate::dsl::{ParseError, parse_command};
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::mode::Mode; use crate::mode::Mode;
/// Composite literal candidates whose lexed shape is more than /// Composite literal candidates whose lexed shape is more than
@@ -275,11 +275,7 @@ pub struct Completion {
/// (case-insensitive starts-with), combined, sorted, and /// (case-insensitive starts-with), combined, sorted, and
/// deduplicated. /// deduplicated.
#[must_use] #[must_use]
pub fn candidates_at_cursor( pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
input: &str,
cursor: usize,
cache: &SchemaCache,
) -> Option<Completion> {
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced) 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(); let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
if run < cursor && bytes[run] == b'-' && word_boundary && run < start { if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode); 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; 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 // walk's `current_table_columns`; fall back to "the union of
// the look-ahead from_scope's bindings' columns" when leading // the look-ahead from_scope's bindings' columns" when leading
// produced no in-scope columns. Phase-1 DSL paths unaffected. // produced no in-scope columns. Phase-1 DSL paths unaffected.
let lookahead_union_columns: Vec<TableColumn> = let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
if probe.current_table_columns.is_none() { let mut out: Vec<TableColumn> = Vec::new();
let mut out: Vec<TableColumn> = Vec::new(); for binding in resolution_from_scope {
for binding in resolution_from_scope { for col in &binding.columns {
for col in &binding.columns { if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
if !out.iter().any(|c| { out.push(col.clone());
c.name.eq_ignore_ascii_case(&col.name)
}) {
out.push(col.clone());
}
} }
} }
out }
} else { out
Vec::new() } else {
}; Vec::new()
};
let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() { let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() {
None None
} else { } else {
@@ -507,30 +504,23 @@ pub fn candidates_at_cursor_with_in_mode(
// column list (the structural error path surfaces the // column list (the structural error path surfaces the
// unresolved-prefix message). // unresolved-prefix message).
let prefix_qualifier = peek_back_qualifier(input, start); let prefix_qualifier = peek_back_qualifier(input, start);
let qualified_columns: Option<Vec<String>> = prefix_qualifier let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
.as_ref() // ADR-0033 §9: `excluded.|` inside an `INSERT … ON
.map(|q| { // CONFLICT … DO UPDATE` completes to the target table's
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON // columns — `excluded` mirrors the would-be-inserted row.
// CONFLICT … DO UPDATE` completes to the target table's // The target's columns are the INSERT's
// columns — `excluded` mirrors the would-be-inserted row. // `current_table_columns` (set by the target-table slot).
// The target's columns are the INSERT's // The diagnostic pass enforces the strict DO-UPDATE
// `current_table_columns` (set by the target-table slot). // byte-range; completion is the softer surface and offers
// The diagnostic pass enforces the strict DO-UPDATE // the columns whenever the INSERT target is in hand.
// byte-range; completion is the softer surface and offers if q.eq_ignore_ascii_case("excluded")
// the columns whenever the INSERT target is in hand. && let Some(cols) = current_table_columns
if q.eq_ignore_ascii_case("excluded") {
&& let Some(cols) = current_table_columns cols.iter().map(|c| c.name.clone()).collect()
{ } else {
cols.iter().map(|c| c.name.clone()).collect() resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
} else { }
resolve_qualifier_columns_in( });
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
}
});
let expected = if probe.expected.is_empty() { let expected = if probe.expected.is_empty() {
expected_at(leading, mode) expected_at(leading, mode)
@@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode(
Some(crate::dsl::grammar::HintMode::ProseOnly(_)) Some(crate::dsl::grammar::HintMode::ProseOnly(_))
); );
if partial_prefix.is_empty() if partial_prefix.is_empty()
&& (prose_only_slot && (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
|| (is_value_literal_signature(&expected) && !has_schema_ident))
{ {
return None; return None;
} }
@@ -646,7 +635,13 @@ pub fn candidates_at_cursor_with_in_mode(
// shortid). The walker surfaces this as // shortid). The walker surfaces this as
// `Expectation::Ident { source: Types }`. // `Expectation::Ident { source: Types }`.
let type_names: Vec<String> = if expected.iter().any(|e| { 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() Type::all()
.iter() .iter()
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
// filtered like every other source; empty prefix offers the whole // filtered like every other source; empty prefix offers the whole
// set. Tagged `CandidateKind::Function` for its own colour. // set. Tagged `CandidateKind::Function` for its own colour.
let has_sql_expr_slot = expected.iter().any(|e| { 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 { let mut functions: Vec<String> = if has_sql_expr_slot {
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS 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` / // curated vocabulary is offered so a learner can discover `email` /
// `product` / … by Tab. Same `Function` kind / `tok_function` colour // `product` / … by Tab. Same `Function` kind / `tok_function` colour
// as SQL functions (no new theme colour — ADR-0048 §Grammar). // as SQL functions (no new theme colour — ADR-0048 §Grammar).
let has_generator_slot = expected let has_generator_slot = expected.iter().any(|e| {
.iter() matches!(
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. })); e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot { if has_generator_slot {
functions.extend( functions.extend(
crate::seed::KNOWN_GENERATORS 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 // (the `typing_over_diag` path) — keeps the alias from flashing as
// a bogus "unknown column" while typing. Mixed into `identifiers` // a bogus "unknown column" while typing. Mixed into `identifiers`
// so it sorts/dedups/colours uniformly with column candidates. // so it sorts/dedups/colours uniformly with column candidates.
let alias_candidates: Vec<String> = let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
if has_sql_expr_slot && prefix_qualifier.is_none() { // Once the partial *exactly* matches an in-scope qualifier,
// Once the partial *exactly* matches an in-scope qualifier, // discoverability is served — the learner has a whole alias
// discoverability is served — the learner has a whole alias // in hand and now needs the "add `.column`" hint
// in hand and now needs the "add `.column`" hint // (`diagnostic.alias_used_as_column`), not sibling aliases
// (`diagnostic.alias_used_as_column`), not sibling aliases // that merely share the prefix. Offering them would also let
// that merely share the prefix. Offering them would also let // the `typing_over_diag` path suppress that very hint. So in
// the `typing_over_diag` path suppress that very hint. So in // the exact-match case we emit no alias candidates and let
// the exact-match case we emit no alias candidates and let // the targeted diagnostic surface.
// the targeted diagnostic surface. let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| { let q = b.alias.as_deref().unwrap_or(b.table.as_str());
let q = b.alias.as_deref().unwrap_or(b.table.as_str()); q.eq_ignore_ascii_case(&partial_prefix)
q.eq_ignore_ascii_case(&partial_prefix) });
}); if partial_is_exact_alias {
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() 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 // Source 2: schema identifiers — accumulated across every
// matching schema-listable `Ident { source }` expectation. // matching schema-listable `Ident { source }` expectation.
@@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode(
let mut identifiers: Vec<String> = expected let mut identifiers: Vec<String> = expected
.iter() .iter()
.filter_map(|e| match e { .filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => { Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
Some(*source)
}
_ => None, _ => None,
}) })
.flat_map(|source| { .flat_map(|source| {
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
.iter() .iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table)) .find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{ {
return cte return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
} }
} }
// Second: table-name match in the active from_scope. // Second: table-name match in the active from_scope.
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
.iter() .iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table)) .find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{ {
return cte return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
} }
} }
// Third: direct cte_bindings match (cte_alias.|). // Third: direct cte_bindings match (cte_alias.|).
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
.iter() .iter()
.find(|c| c.name.eq_ignore_ascii_case(qualifier)) .find(|c| c.name.eq_ignore_ascii_case(qualifier))
{ {
return cte return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
} }
// Fourth: a bare table name from the schema cache — DSL // Fourth: a bare table name from the schema cache — DSL
// paths reach this for `from <Table>.<col>` shapes where // 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 // column. So `select Agx` warns at typing time again, while
// `select sum` does not. // `select sum` does not.
let has_sql_expr_slot = expected.iter().any(|e| { 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) { if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
return None; 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 // schema-column check below would never see it. A partial that
// prefix-matches a known generator is an in-progress name; anything // prefix-matches a known generator is an in-progress name; anything
// else is an unknown generator → flag it `[ERR]` while typing. // else is an unknown generator → flag it `[ERR]` while typing.
let has_generator_slot = expected let has_generator_slot = expected.iter().any(|e| {
.iter() matches!(
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. })); e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot { if has_generator_slot {
if crate::seed::is_known_generator_prefix(partial) { if crate::seed::is_known_generator_prefix(partial) {
return None; return None;
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
let sources: Vec<IdentSource> = expected let sources: Vec<IdentSource> = expected
.iter() .iter()
.filter_map(|e| match e { .filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => { Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
Some(*source)
}
_ => None, _ => None,
}) })
.collect(); .collect();
@@ -1412,13 +1413,15 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
fn cands(input: &str, cursor: usize) -> Vec<String> { fn cands(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor(input, cursor, &SchemaCache::default()) candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) c.candidates.into_iter().map(|c| c.text).collect()
})
} }
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> { fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
candidates_at_cursor(input, cursor, cache) candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) c.candidates.into_iter().map(|c| c.text).collect()
})
} }
/// Simple-mode completion candidates — the DSL surface /// Simple-mode completion candidates — the DSL surface
@@ -1429,7 +1432,9 @@ mod tests {
/// Advanced mode surfaces the SQL grammar's completions instead. /// Advanced mode surfaces the SQL grammar's completions instead.
fn cands_simple(input: &str, cursor: usize) -> Vec<String> { fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple) 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( fn cand_kinds_with(
@@ -1438,10 +1443,7 @@ mod tests {
cache: &SchemaCache, cache: &SchemaCache,
) -> Vec<(String, CandidateKind)> { ) -> Vec<(String, CandidateKind)> {
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| { candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
.into_iter()
.map(|c| (c.text, c.kind))
.collect()
}) })
} }
@@ -1503,12 +1505,21 @@ mod tests {
// Simple-only (column, relationship, constraint). // Simple-only (column, relationship, constraint).
let cs = cands("drop ", 5); let cs = cands("drop ", 5);
for kw in ["table", "index", "column", "relationship", "constraint"] { 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. // Both-mode continuations block before the simple-only ones.
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap(); let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}"); assert!(
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}"); pos("table") < pos("column"),
"Both block precedes Simple block: {cs:?}"
);
assert!(
pos("index") < pos("relationship"),
"Both block precedes Simple block: {cs:?}"
);
} }
#[test] #[test]
@@ -1631,8 +1642,14 @@ mod tests {
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default()) let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
.expect("a `-` at a flag position offers candidates"); .expect("a `-` at a flag position offers candidates");
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect(); 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!(
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}"); texts.contains(&"--create-fk"),
"should offer --create-fk: {texts:?}"
);
assert!(
!texts.contains(&"on"),
"must NOT offer `on` after a dash: {texts:?}"
);
assert_eq!( assert_eq!(
c.replaced_range, c.replaced_range,
(input.len() - 1, input.len()), (input.len() - 1, input.len()),
@@ -1643,13 +1660,9 @@ mod tests {
#[test] #[test]
fn double_dash_replaces_both_dashes_on_accept() { fn double_dash_replaces_both_dashes_on_accept() {
let input = "delete from T --"; let input = "delete from T --";
let c = candidates_at_cursor_in_mode( let c =
input, candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
input.len(), .expect("`--` offers the flag");
&SchemaCache::default(),
Mode::Simple,
)
.expect("`--` offers the flag");
assert!(c.candidates.iter().any(|x| x.text == "--all-rows")); assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
assert_eq!( assert_eq!(
c.replaced_range, c.replaced_range,
@@ -1668,9 +1681,7 @@ mod tests {
s.tables.push("T".into()); s.tables.push("T".into());
s.columns.push("x".into()); s.columns.push("x".into());
let input = "show data T where x = -5"; let input = "show data T where x = -5";
if let Some(c) = if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
{
assert!( assert!(
!c.candidates.iter().any(|x| x.text.starts_with("--")), !c.candidates.iter().any(|x| x.text.starts_with("--")),
"no flags at a value position: {:?}", "no flags at a value position: {:?}",
@@ -1715,8 +1726,8 @@ mod tests {
// App-lifecycle commands now appear alongside DSL // App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set. // commands in the entry-keyword set.
for expected in &[ for expected in &[
"quit", "help", "rebuild", "save", "new", "load", "export", "quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
"import", "mode", "messages", "undo", "redo", "copy", "messages", "undo", "redo", "copy",
] { ] {
assert!( assert!(
cs.contains(&expected.to_string()), cs.contains(&expected.to_string()),
@@ -1943,7 +1954,10 @@ mod tests {
// opening a sub-shape) becomes a Tab candidate. // opening a sub-shape) becomes a Tab candidate.
let input = "add column to table T"; let input = "add column to table T";
let cs = cands(input, input.len()); 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] #[test]
@@ -1957,10 +1971,7 @@ mod tests {
assert!(cs.contains(&"(".to_string()), "got {cs:?}"); assert!(cs.contains(&"(".to_string()), "got {cs:?}");
} }
fn schema_with_table( fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
table: &str,
columns: &[(&str, crate::dsl::types::Type)],
) -> SchemaCache {
let mut cache = SchemaCache::default(); let mut cache = SchemaCache::default();
cache.tables.push(table.to_string()); cache.tables.push(table.to_string());
let cols: Vec<TableColumn> = columns let cols: Vec<TableColumn> = columns
@@ -2002,8 +2013,14 @@ mod tests {
let cache = two_table_alias_cache(); 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 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); let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}"); assert!(
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}"); 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] #[test]
@@ -2015,8 +2032,14 @@ mod tests {
let cache = two_table_alias_cache(); 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 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); let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}"); assert!(
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}"); 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. // 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"; 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 // SchemaCache.columns has columns from many tables, but
// at `update Customers set ` only Customers' columns // at `update Customers set ` only Customers' columns
// should appear. // should appear.
let mut cache = schema_with_table( let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
// Pretend the global flat list has columns from a second // Pretend the global flat list has columns from a second
// table that aren't in Customers. // table that aren't in Customers.
cache.columns.push("OrderTotal".to_string()); cache.columns.push("OrderTotal".to_string());
cache.columns.push("Stock".to_string()); cache.columns.push("Stock".to_string());
cache cache.table_columns.insert(
.table_columns "Orders".to_string(),
.insert("Orders".to_string(), vec![ vec![TableColumn {
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false }, name: "OrderTotal".to_string(),
]); user_type: Type::Real,
not_null: false,
has_default: false,
}],
);
cache.tables.push("Orders".to_string()); cache.tables.push("Orders".to_string());
let cs = cands_with("update Customers set ", 21, &cache); let cs = cands_with("update Customers set ", 21, &cache);
// Customers's columns should appear: // Customers's columns should appear:
@@ -2079,10 +2103,7 @@ mod tests {
// *before* ORDER BY (the FROM's JOIN options, WHERE / // *before* ORDER BY (the FROM's JOIN options, WHERE /
// GROUP BY / HAVING, set-ops). Those used to shove the // GROUP BY / HAVING, set-ops). Those used to shove the
// columns off-screen. // columns off-screen.
let cache = schema_with_table( let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let input = "select Name from Things order by "; let input = "select Name from Things order by ";
let cs = cands_with(input, input.len(), &cache); let cs = cands_with(input, input.len(), &cache);
// The columns the user wants are offered: // The columns the user wants are offered:
@@ -2090,8 +2111,19 @@ mod tests {
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}"); assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
// Preceding-clause keywords must not leak in: // Preceding-clause keywords must not leak in:
for kw in [ for kw in [
"where", "group", "having", "join", "union", "intersect", "where",
"except", "left", "right", "full", "cross", "inner", "as", "group",
"having",
"join",
"union",
"intersect",
"except",
"left",
"right",
"full",
"cross",
"inner",
"as",
] { ] {
assert!( assert!(
!cs.contains(&kw.to_string()), !cs.contains(&kw.to_string()),
@@ -2108,10 +2140,7 @@ mod tests {
// sort item the direction keywords surface as // sort item the direction keywords surface as
// continuations (previously discarded at the Repeated // continuations (previously discarded at the Repeated
// boundary, so completion offered neither). // boundary, so completion offered neither).
let cache = schema_with_table( let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let input = "select Name from Things order by Name "; let input = "select Name from Things order by Name ";
let cs = cands_with(input, input.len(), &cache); let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"asc".to_string()), "got {cs:?}"); assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
@@ -2123,10 +2152,7 @@ mod tests {
use crate::dsl::types::Type; use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete // walk_repeated trailing-optional fix: after a complete
// projection item the `as` alias keyword surfaces. // projection item the `as` alias keyword surfaces.
let cache = schema_with_table( let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let input = "select Name "; let input = "select Name ";
let cs = cands_with(input, input.len(), &cache); let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"as".to_string()), "got {cs:?}"); assert!(cs.contains(&"as".to_string()), "got {cs:?}");
@@ -2153,16 +2179,13 @@ mod tests {
// ADR-0022 Amendment 2: at an expression position offering // ADR-0022 Amendment 2: at an expression position offering
// both column names and keywords, every column precedes // both column names and keywords, every column precedes
// every keyword so the names stay visible by default. // every keyword so the names stay visible by default.
let cache = schema_with_table( let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let input = "select * from Things where "; let input = "select * from Things where ";
let cs = cands_with(input, input.len(), &cache); let cs = cands_with(input, input.len(), &cache);
let pos = |needle: &str| { let pos = |needle: &str| {
cs.iter().position(|c| c == needle).unwrap_or_else(|| { cs.iter()
panic!("{needle:?} not in candidates: {cs:?}") .position(|c| c == needle)
}) .unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
}; };
// Both columns come before any expression-start keyword. // Both columns come before any expression-start keyword.
let last_ident = pos("Name").max(pos("Qty")); let last_ident = pos("Name").max(pos("Qty"));
@@ -2176,13 +2199,9 @@ mod tests {
#[test] #[test]
fn update_where_offers_only_current_table_columns() { fn update_where_offers_only_current_table_columns() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let mut cache = schema_with_table( let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
cache.columns.push("OrderTotal".to_string()); cache.columns.push("OrderTotal".to_string());
let cs = let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
cands_with("update Customers set Email='x' where ", 37, &cache);
assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}"); assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2208,7 +2227,11 @@ mod tests {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_table( let cache = schema_with_table(
"Customers", "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); let cs = cands_with("insert into Customers (", 23, &cache);
// The user is at Form A's column-list position. All // The user is at Form A's column-list position. All
@@ -2222,10 +2245,7 @@ mod tests {
#[test] #[test]
fn insert_into_open_paren_does_not_offer_unrelated_columns() { fn insert_into_open_paren_does_not_offer_unrelated_columns() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let mut cache = schema_with_table( let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
cache.columns.push("OrderTotal".to_string()); cache.columns.push("OrderTotal".to_string());
let cs = cands_with("insert into Customers (", 23, &cache); let cs = cands_with("insert into Customers (", 23, &cache);
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}"); assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2239,13 +2259,9 @@ mod tests {
// table's columns. `OrderTotal` belongs to no table in // table's columns. `OrderTotal` belongs to no table in
// this cache's `table_columns`, so it must not leak. // this cache's `table_columns`, so it must not leak.
use crate::dsl::types::Type; use crate::dsl::types::Type;
let mut cache = schema_with_table( let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
cache.columns.push("OrderTotal".to_string()); cache.columns.push("OrderTotal".to_string());
let cs = let cs = cands_with("drop column from Customers: ", 28, &cache);
cands_with("drop column from Customers: ", 28, &cache);
assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!( assert!(
@@ -2271,8 +2287,8 @@ mod tests {
#[test] #[test]
fn cursor_mid_keyword_replaces_only_the_partial_prefix() { fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default()) let comp =
.expect("some completion"); candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (0, 3)); assert_eq!(comp.replaced_range, (0, 3));
assert_eq!(comp.partial_prefix, "cre"); assert_eq!(comp.partial_prefix, "cre");
assert_eq!(comp.candidates.len(), 1); assert_eq!(comp.candidates.len(), 1);
@@ -2282,8 +2298,8 @@ mod tests {
#[test] #[test]
fn cursor_at_word_boundary_has_empty_partial_prefix() { fn cursor_at_word_boundary_has_empty_partial_prefix() {
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default()) let comp =
.expect("some completion"); candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (7, 7)); assert_eq!(comp.replaced_range, (7, 7));
assert_eq!(comp.partial_prefix, ""); assert_eq!(comp.partial_prefix, "");
} }
@@ -2517,8 +2533,8 @@ mod tests {
// inside `Name`, and substituting any name there // inside `Name`, and substituting any name there
// produces a complete command. No useful "next after // produces a complete command. No useful "next after
// name" hint. // name" hint.
let t = typing_name_at_cursor("add column to table T: Name (text)", 27) let t =
.expect("should fire"); typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
assert_eq!(t.next_after_name, None); 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()); assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
// `show data Cust` plus a typo: `show data Custp`. No // `show data Cust` plus a typo: `show data Custp`. No
// table starts with "Custp" → invalid. // table starts with "Custp" → invalid.
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache) let invalid =
.expect("should be invalid"); invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
assert_eq!(invalid.range, (10, 15)); assert_eq!(invalid.range, (10, 15));
assert_eq!(invalid.found, "Custp"); assert_eq!(invalid.found, "Custp");
assert_eq!(invalid.source, IdentSource::Tables); assert_eq!(invalid.source, IdentSource::Tables);
@@ -2600,7 +2616,11 @@ mod tests {
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"), !cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
"NewName slot must not surface schema candidates; got {cs:?}" "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 { fn keyword_cand(text: &str) -> Candidate {
@@ -2791,8 +2811,10 @@ mod tests {
let cands = candidates_at_cursor(input, input.len(), &cache) let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion") .expect("some completion")
.candidates; .candidates;
let count_entries: Vec<_> = let count_entries: Vec<_> = cands
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect(); .iter()
.filter(|c| c.text.eq_ignore_ascii_case("count"))
.collect();
assert_eq!( assert_eq!(
count_entries.len(), count_entries.len(),
1, 1,
@@ -2805,7 +2827,9 @@ mod tests {
); );
// A non-colliding function at the same slot is unaffected. // A non-colliding function at the same slot is unaffected.
assert!( 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:?}", "non-colliding functions still surface; got {cands:?}",
); );
} }
@@ -2875,8 +2899,10 @@ mod tests {
let mut s = SchemaCache::default(); let mut s = SchemaCache::default();
s.tables.push("OrderLines".into()); s.tables.push("OrderLines".into());
s.columns.push("count".into()); s.columns.push("count".into());
s.table_columns s.table_columns.insert(
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]); "OrderLines".into(),
vec![TableColumn::new("count", Type::Int)],
);
let input = "select sum(ol.count) from OrderLines ol"; let input = "select sum(ol.count) from OrderLines ol";
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol` let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
assert!( assert!(
@@ -2938,15 +2964,35 @@ mod tests {
s.table_columns.insert( s.table_columns.insert(
"a".to_string(), "a".to_string(),
vec![ vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, TableColumn {
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false }, 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( s.table_columns.insert(
"b".to_string(), "b".to_string(),
vec![ vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, TableColumn {
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false }, 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 s
@@ -3191,5 +3237,3 @@ mod tests {
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none()); 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
+10 -11
View File
@@ -549,9 +549,7 @@ pub enum AppCommand {
/// word like `insert` / `create` / `show`, or `types`), the /// word like `insert` / `create` / `show`, or `types`), the
/// focused detail for that command (or command group sharing /// focused detail for that command (or command group sharing
/// the entry word). /// the entry word).
Help { Help { topic: Option<String> },
topic: Option<String>,
},
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument: /// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error /// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is /// (the buffer is empty post-submit). The live-input surface is
@@ -580,7 +578,10 @@ pub enum AppCommand {
/// Unpack a zip into a new project and switch to it. /// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from /// `target` overrides the project name (default: taken from
/// the zip). /// the zip).
Import { path: String, target: Option<String> }, Import {
path: String,
target: Option<String>,
},
/// Switch the persistent input mode. /// Switch the persistent input mode.
Mode { value: ModeValue }, Mode { value: ModeValue },
/// Show or set the messages verbosity. /// Show or set the messages verbosity.
@@ -791,9 +792,7 @@ impl PartialEq for Operand {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
match (self, other) { match (self, other) {
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b, (Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => { (Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
a == b
}
_ => false, _ => false,
} }
} }
@@ -817,7 +816,9 @@ pub enum CompareOp {
/// a single row in the metadata table. /// a single row in the metadata table.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationshipSelector { pub enum RelationshipSelector {
Named { name: String }, Named {
name: String,
},
Endpoints { Endpoints {
parent_table: String, parent_table: String,
parent_column: String, parent_column: String,
@@ -1156,9 +1157,7 @@ impl Command {
parent_column, parent_column,
child_table, child_table,
child_column, child_column,
} => format!( } => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
),
}, },
// A constraint command's subject is the dotted // A constraint command's subject is the dotted
// `<table>.<column>` it acts on (ADR-0029 §2.2). // `<table>.<column>` it acts on (ADR-0029 §2.2).
+41 -30
View File
@@ -9,8 +9,7 @@
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue}; use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
use crate::dsl::grammar::{ use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
Word,
}; };
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath}; use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const IMPORT_TARGET: Node = Node::Hinted { const IMPORT_TARGET: Node = Node::Hinted {
mode: HintMode::ForceProse("hint.ambient_typing_name"), mode: HintMode::ForceProse("hint.ambient_typing_name"),
inner: &IMPORT_TARGET_IDENT, inner: &IMPORT_TARGET_IDENT,
}; };
const IMPORT_AS_TARGET: Node = Node::Seq(&[ const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
Node::Word(Word::keyword("as")),
IMPORT_TARGET,
]);
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_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]); 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_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
]; ];
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES); const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
@@ -119,9 +115,9 @@ const MESSAGES_CHOICES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
]; ];
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES); const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
@@ -271,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode {
ast_builder: build_quit, ast_builder: build_quit,
help_id: Some("app.quit"), help_id: Some("app.quit"),
hint_ids: &["quit"], hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],}; usage_ids: &["parse.usage.quit"],
};
pub static HELP: CommandNode = CommandNode { pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"), entry: Word::keyword("help"),
@@ -279,7 +276,8 @@ pub static HELP: CommandNode = CommandNode {
ast_builder: build_help, ast_builder: build_help,
help_id: Some("app.help"), help_id: Some("app.help"),
hint_ids: &["help"], hint_ids: &["help"],
usage_ids: &["parse.usage.help"],}; usage_ids: &["parse.usage.help"],
};
pub static HINT: CommandNode = CommandNode { pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"), entry: Word::keyword("hint"),
@@ -288,7 +286,8 @@ pub static HINT: CommandNode = CommandNode {
help_id: Some("app.hint"), help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053). // hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_ids: &["hint"], hint_ids: &["hint"],
usage_ids: &["parse.usage.hint"],}; usage_ids: &["parse.usage.hint"],
};
pub static REBUILD: CommandNode = CommandNode { pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"), entry: Word::keyword("rebuild"),
@@ -296,7 +295,8 @@ pub static REBUILD: CommandNode = CommandNode {
ast_builder: build_rebuild, ast_builder: build_rebuild,
help_id: Some("app.rebuild"), help_id: Some("app.rebuild"),
hint_ids: &["rebuild"], hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],}; usage_ids: &["parse.usage.rebuild"],
};
pub static VERSION: CommandNode = CommandNode { pub static VERSION: CommandNode = CommandNode {
entry: Word::keyword("version"), entry: Word::keyword("version"),
@@ -304,7 +304,8 @@ pub static VERSION: CommandNode = CommandNode {
ast_builder: build_version, ast_builder: build_version,
help_id: Some("app.version"), help_id: Some("app.version"),
hint_ids: &["version"], hint_ids: &["version"],
usage_ids: &["parse.usage.version"],}; usage_ids: &["parse.usage.version"],
};
pub static SAVE: CommandNode = CommandNode { pub static SAVE: CommandNode = CommandNode {
entry: Word::keyword("save"), entry: Word::keyword("save"),
@@ -312,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode {
ast_builder: build_save, ast_builder: build_save,
help_id: Some("app.save"), help_id: Some("app.save"),
hint_ids: &["save"], hint_ids: &["save"],
usage_ids: &["parse.usage.save"],}; usage_ids: &["parse.usage.save"],
};
pub static NEW: CommandNode = CommandNode { pub static NEW: CommandNode = CommandNode {
entry: Word::keyword("new"), entry: Word::keyword("new"),
@@ -320,7 +322,8 @@ pub static NEW: CommandNode = CommandNode {
ast_builder: build_new, ast_builder: build_new,
help_id: Some("app.new"), help_id: Some("app.new"),
hint_ids: &["new"], hint_ids: &["new"],
usage_ids: &["parse.usage.new"],}; usage_ids: &["parse.usage.new"],
};
pub static LOAD: CommandNode = CommandNode { pub static LOAD: CommandNode = CommandNode {
entry: Word::keyword("load"), entry: Word::keyword("load"),
@@ -328,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode {
ast_builder: build_load, ast_builder: build_load,
help_id: Some("app.load"), help_id: Some("app.load"),
hint_ids: &["load"], hint_ids: &["load"],
usage_ids: &["parse.usage.load"],}; usage_ids: &["parse.usage.load"],
};
pub static EXPORT: CommandNode = CommandNode { pub static EXPORT: CommandNode = CommandNode {
entry: Word::keyword("export"), entry: Word::keyword("export"),
@@ -336,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode {
ast_builder: build_export, ast_builder: build_export,
help_id: Some("app.export"), help_id: Some("app.export"),
hint_ids: &["export"], hint_ids: &["export"],
usage_ids: &["parse.usage.export"],}; usage_ids: &["parse.usage.export"],
};
pub static IMPORT: CommandNode = CommandNode { pub static IMPORT: CommandNode = CommandNode {
entry: Word::keyword("import"), entry: Word::keyword("import"),
@@ -344,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode {
ast_builder: build_import, ast_builder: build_import,
help_id: Some("app.import"), help_id: Some("app.import"),
hint_ids: &["import"], hint_ids: &["import"],
usage_ids: &["parse.usage.import"],}; usage_ids: &["parse.usage.import"],
};
pub static MODE: CommandNode = CommandNode { pub static MODE: CommandNode = CommandNode {
entry: Word::keyword("mode"), entry: Word::keyword("mode"),
@@ -352,7 +358,8 @@ pub static MODE: CommandNode = CommandNode {
ast_builder: build_mode, ast_builder: build_mode,
help_id: Some("app.mode"), help_id: Some("app.mode"),
hint_ids: &["mode"], hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],}; usage_ids: &["parse.usage.mode"],
};
pub static MESSAGES: CommandNode = CommandNode { pub static MESSAGES: CommandNode = CommandNode {
entry: Word::keyword("messages"), entry: Word::keyword("messages"),
@@ -360,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode {
ast_builder: build_messages, ast_builder: build_messages,
help_id: Some("app.messages"), help_id: Some("app.messages"),
hint_ids: &["messages"], hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],}; usage_ids: &["parse.usage.messages"],
};
pub static UNDO: CommandNode = CommandNode { pub static UNDO: CommandNode = CommandNode {
entry: Word::keyword("undo"), entry: Word::keyword("undo"),
@@ -368,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode {
ast_builder: build_undo, ast_builder: build_undo,
help_id: Some("app.undo"), help_id: Some("app.undo"),
hint_ids: &["undo"], hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],}; usage_ids: &["parse.usage.undo"],
};
pub static REDO: CommandNode = CommandNode { pub static REDO: CommandNode = CommandNode {
entry: Word::keyword("redo"), entry: Word::keyword("redo"),
@@ -376,7 +385,8 @@ pub static REDO: CommandNode = CommandNode {
ast_builder: build_redo, ast_builder: build_redo,
help_id: Some("app.redo"), help_id: Some("app.redo"),
hint_ids: &["redo"], hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],}; usage_ids: &["parse.usage.redo"],
};
pub static COPY: CommandNode = CommandNode { pub static COPY: CommandNode = CommandNode {
entry: Word::keyword("copy"), entry: Word::keyword("copy"),
@@ -384,4 +394,5 @@ pub static COPY: CommandNode = CommandNode {
ast_builder: build_copy, ast_builder: build_copy,
help_id: Some("app.copy"), help_id: Some("app.copy"),
hint_ids: &["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 //! later swap that capture for the same typed slots used here, adding
//! live hints/highlighting. //! live hints/highlighting.
use crate::dsl::command::{ use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
};
use crate::dsl::grammar::{ use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr, CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{ shared::{
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
current_column_value, insert_target_columns, insert_target_columns,
}, },
sql_delete, sql_insert, sql_select, sql_update, sql_delete, sql_insert, sql_select, sql_update,
}; };
use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value; use crate::dsl::value::Value;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath}; use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// ================================================================= // =================================================================
@@ -56,10 +54,10 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
/// Table-name slot variant that populates /// Table-name slot variant that populates
@@ -75,10 +73,10 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
highlight_override: None, highlight_override: None,
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: 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_DATA: Node = Node::Seq(SHOW_DATA_NODES);
const SHOW_TABLE_NODES: &[Node] = &[ const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES); const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
// `show tables` / `show relationships` / `show indexes` — the // `show tables` / `show relationships` / `show indexes` — the
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const SHOW_INDEX_NODES: &[Node] = const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES); const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
const SHOW_CHOICES: &[Node] = &[ const SHOW_CHOICES: &[Node] = &[
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: true, writes_user_listed_column: true,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
static INSERT_COMMA: Node = Node::Punct(','); 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. /// or an identifier-shaped token (a column name) returns false.
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool { fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{ use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal, consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
skip_whitespace,
}; };
let p = skip_whitespace(source, pos); let p = skip_whitespace(source, pos);
if p >= source.len() { if p >= source.len() {
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
return FALLBACK_VALUE_LIST; return FALLBACK_VALUE_LIST;
}; };
let (count, closed) = count_tuple_values(source, pos); 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 { if arity_ok {
Node::DynamicSubgrammar(column_value_list) Node::DynamicSubgrammar(column_value_list)
} else { } 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_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
const INSERT_AFTER_TABLE_CHOICES: &[Node] = const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES); const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
const INSERT_NODES: &[Node] = &[ const INSERT_NODES: &[Node] = &[
@@ -349,10 +345,10 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
highlight_override: None, highlight_override: None,
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
/// Column-name slot in `set col = …` — resolves the column's /// Column-name slot in `set col = …` — resolves the column's
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: true, writes_column: true,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
/// Value slot resolved at walk time from /// Value slot resolved at walk time from
@@ -376,11 +372,7 @@ writes_projection_alias: false,
/// value-literal choice when no current_column is bound. /// value-literal choice when no current_column is bound.
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value); const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[ const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
SET_COLUMN,
Node::Punct('='),
PER_COLUMN_VALUE,
];
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES); const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
const UPDATE_ASSIGNMENTS: Node = Node::Repeated { const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
inner: &UPDATE_ASSIGNMENT, inner: &UPDATE_ASSIGNMENT,
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
separator: Some(&Node::Punct(',')), separator: Some(&Node::Punct(',')),
min: 1, min: 1,
}; };
const SEED_SET_CLAUSE_NODES: &[Node] = const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES); const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
const SEED_NODES: &[Node] = &[ const SEED_NODES: &[Node] = &[
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("in") => { MatchedKind::Word("in") => {
*i += 1; // `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; *i += 1;
} }
let mut values = Vec::new(); let mut values = Vec::new();
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("between") => { MatchedKind::Word("between") => {
*i += 1; // `between` *i += 1; // `between`
let low = seed_take_value(region, i, column)?; 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; *i += 1;
} }
let high = seed_take_value(region, i, column)?; let high = seed_take_value(region, i, column)?;
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
*i += 1; // `as` *i += 1; // `as`
let gen_item = region let gen_item = region
.get(*i) .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))?; .ok_or_else(|| seed_set_error(column))?;
*i += 1; *i += 1;
Ok(SeedOverrideKind::Generator(gen_item.text.clone())) 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 let table_idx = path
.items .items
.iter() .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 { .ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "missing table".to_string())], 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() { if columns.is_empty() {
return Err(ValidationError { return Err(ValidationError {
message_key: "parse.error_wrapper", 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 // 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( fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
path: &MatchedPath,
) -> Result<Vec<(String, Value)>, ValidationError> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut iter = path.items.iter(); let mut iter = path.items.iter();
while let Some(item) = iter.next() { 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 let row_source = path
.items .items
.iter() .iter()
.find(|item| { .find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
})
.map(|item| { .map(|item| {
let end = tail_start.unwrap_or(source.len()); let end = tail_start.unwrap_or(source.len());
source[item.span.0..end] source[item.span.0..end]
@@ -1805,7 +1817,8 @@ pub static SHOW: CommandNode = CommandNode {
"parse.usage.show_indexes", "parse.usage.show_indexes",
"parse.usage.show_relationship", "parse.usage.show_relationship",
"parse.usage.show_index", "parse.usage.show_index",
],}; ],
};
pub static SEED: CommandNode = CommandNode { pub static SEED: CommandNode = CommandNode {
entry: Word::keyword("seed"), entry: Word::keyword("seed"),
@@ -1823,7 +1836,8 @@ pub static INSERT: CommandNode = CommandNode {
help_id: Some("data.insert"), help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar. // ADR-0053 Phase-B exemplar.
hint_ids: &["insert"], hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],}; usage_ids: &["parse.usage.insert"],
};
pub static UPDATE: CommandNode = CommandNode { pub static UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"), entry: Word::keyword("update"),
@@ -1831,7 +1845,8 @@ pub static UPDATE: CommandNode = CommandNode {
ast_builder: build_update, ast_builder: build_update,
help_id: Some("data.update"), help_id: Some("data.update"),
hint_ids: &["update"], hint_ids: &["update"],
usage_ids: &["parse.usage.update"],}; usage_ids: &["parse.usage.update"],
};
pub static DELETE: CommandNode = CommandNode { pub static DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"), entry: Word::keyword("delete"),
@@ -1839,7 +1854,8 @@ pub static DELETE: CommandNode = CommandNode {
ast_builder: build_delete, ast_builder: build_delete,
help_id: Some("data.delete"), help_id: Some("data.delete"),
hint_ids: &["delete"], hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],}; usage_ids: &["parse.usage.delete"],
};
pub static REPLAY: CommandNode = CommandNode { pub static REPLAY: CommandNode = CommandNode {
entry: Word::keyword("replay"), entry: Word::keyword("replay"),
@@ -1847,7 +1863,8 @@ pub static REPLAY: CommandNode = CommandNode {
ast_builder: build_replay, ast_builder: build_replay,
help_id: Some("data.replay"), help_id: Some("data.replay"),
hint_ids: &["replay"], hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],}; usage_ids: &["parse.usage.replay"],
};
pub static EXPLAIN: CommandNode = CommandNode { pub static EXPLAIN: CommandNode = CommandNode {
entry: Word::keyword("explain"), entry: Word::keyword("explain"),
@@ -1855,7 +1872,8 @@ pub static EXPLAIN: CommandNode = CommandNode {
ast_builder: build_explain, ast_builder: build_explain,
help_id: Some("data.explain"), help_id: Some("data.explain"),
hint_ids: &["explain"], hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],}; usage_ids: &["parse.usage.explain"],
};
/// `explain` over advanced-mode SQL (ADR-0039). /// `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. // precedent; otherwise `note_help` would print `explain` twice.
help_id: None, help_id: None,
hint_ids: &["explain_sql"], hint_ids: &["explain_sql"],
usage_ids: &[],}; usage_ids: &[],
};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
/// ///
@@ -1891,7 +1910,8 @@ pub static SELECT: CommandNode = CommandNode {
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: None,
hint_ids: &["select"], hint_ids: &["select"],
usage_ids: &["parse.usage.select"],}; usage_ids: &["parse.usage.select"],
};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
/// ///
@@ -1906,7 +1926,8 @@ pub static WITH: CommandNode = CommandNode {
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: None,
hint_ids: &["with"], hint_ids: &["with"],
usage_ids: &["parse.usage.with"],}; usage_ids: &["parse.usage.with"],
};
/// SQL `INSERT` — the `Advanced`-category node of the shared /// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j). /// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
@@ -1993,7 +2014,11 @@ mod explain_tests {
#[test] #[test]
fn explain_show_data_carries_where_and_limit_through() { fn explain_show_data_carries_where_and_limit_through() {
match explain_inner("explain show data Customers where id = 1 limit 5") { 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_eq!(name, "Customers");
assert!(filter.is_some(), "where clause should survive"); assert!(filter.is_some(), "where clause should survive");
assert_eq!(limit, Some(5)); assert_eq!(limit, Some(5));
@@ -2052,9 +2077,7 @@ mod explain_tests {
/// Advanced-mode counterpart of `explain_inner`. /// Advanced-mode counterpart of `explain_inner`.
fn explain_inner_adv(input: &str) -> Command { fn explain_inner_adv(input: &str) -> Command {
match parse_command_in_mode(input, Mode::Advanced) match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
.expect("advanced explain should parse")
{
Command::Explain { query } => *query, Command::Explain { query } => *query,
other => panic!("expected Command::Explain, got {other:?}"), other => panic!("expected Command::Explain, got {other:?}"),
} }
@@ -2085,7 +2108,9 @@ mod explain_tests {
#[test] #[test]
fn explain_sql_insert_wraps_a_sql_insert() { fn explain_sql_insert_wraps_a_sql_insert() {
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") { 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!(target_table, "Customers");
assert_eq!(sql, "insert into Customers values (1, 'Bo')"); assert_eq!(sql, "insert into Customers values (1, 'Bo')");
} }
@@ -2096,7 +2121,9 @@ mod explain_tests {
#[test] #[test]
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() { fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") { 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!(target_table, "Customers");
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1"); assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
} }
@@ -2107,7 +2134,9 @@ mod explain_tests {
#[test] #[test]
fn explain_sql_delete_wraps_a_sql_delete() { fn explain_sql_delete_wraps_a_sql_delete() {
match explain_inner_adv("explain delete from Customers where id = 1") { 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!(target_table, "Customers");
assert_eq!(sql, "delete from Customers where id = 1"); assert_eq!(sql, "delete from Customers where id = 1");
} }
@@ -2148,11 +2177,7 @@ mod explain_tests {
fn explain_does_not_cover_ddl() { fn explain_does_not_cover_ddl() {
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039 // EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
// out of scope); there is no SQL DDL branch under explain. // out of scope); there is no SQL DDL branch under explain.
assert!(parse_command_in_mode( assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
"explain create table T (id int)",
Mode::Advanced,
)
.is_err());
} }
#[test] #[test]
@@ -2165,9 +2190,8 @@ mod explain_tests {
use crate::completion::candidates_at_cursor_in_mode; use crate::completion::candidates_at_cursor_in_mode;
let schema = crate::completion::SchemaCache::default(); let schema = crate::completion::SchemaCache::default();
let input = "explain "; let input = "explain ";
let completion = let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced) .expect("explain offers candidates");
.expect("explain offers candidates");
let names: Vec<&str> = completion let names: Vec<&str> = completion
.candidates .candidates
.iter() .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, AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint, IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
}; };
use crate::dsl::value::Value;
use crate::dsl::grammar::{ use crate::dsl::grammar::{
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word, CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR}, shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
}; };
use crate::dsl::value::Value;
/// `HintMode` annotation shared by every `NewName` ident slot: /// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the /// 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", role: "table_name",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const TABLE_NAME_NEW: Node = Node::Hinted { const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -63,12 +63,12 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
role: "table_name", role: "table_name",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const COLUMN_NAME: Node = Node::Ident { const COLUMN_NAME: Node = Node::Ident {
@@ -76,12 +76,12 @@ const COLUMN_NAME: Node = Node::Ident {
role: "column_name", role: "column_name",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident { const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
@@ -89,12 +89,12 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
role: "column_name", role: "column_name",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const COLUMN_NAME_NEW: Node = Node::Hinted { const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -106,12 +106,12 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
role: "relationship_name", role: "relationship_name",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident { const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
@@ -119,12 +119,12 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
role: "relationship_name", role: "relationship_name",
validator: None, validator: None,
highlight_override: None, highlight_override: None,
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted { const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const INDEX_NAME_NEW_IDENT: Node = Node::Ident { const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const INDEX_NAME_NEW: Node = Node::Hinted { const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// drop_table — `drop table <T>` // drop_table — `drop table <T>`
// ================================================================= // =================================================================
const DROP_TABLE_NODES: &[Node] = &[ const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES); const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4, // 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 // plus the optional `IF EXISTS` no-op-with-note. The leading concrete
// `table` keyword (not the Optional) keeps the element/dispatch // `table` keyword (not the Optional) keeps the element/dispatch
// matching honest. // matching honest.
static SQL_DROP_IF_EXISTS_NODES: &[Node] = static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))]; 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)); const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[ static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")), Node::Word(Word::keyword("table")),
@@ -257,9 +256,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -270,9 +269,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
]; ];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES); const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
@@ -286,9 +285,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: true, writes_table: true,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -299,9 +298,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
]; ];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES); 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_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES); const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
const DROP_RELATIONSHIP_NODES: &[Node] = &[ const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
Node::Word(Word::keyword("relationship")),
DR_SELECTOR,
];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES); 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_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES); const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[ const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES); const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// ================================================================= // =================================================================
// drop entry — `drop (table|column|relationship|index) ...` // drop entry — `drop (table|column|relationship|index) ...`
// ================================================================= // =================================================================
const DROP_CHOICES: &[Node] = const DROP_CHOICES: &[Node] = &[
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT]; DROP_COLUMN,
DROP_RELATIONSHIP,
DROP_TABLE,
DROP_INDEX,
DROP_CONSTRAINT,
];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES); const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// ================================================================= // =================================================================
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
separator: Some(&Node::Punct(',')), separator: Some(&Node::Punct(',')),
min: 1, min: 1,
}; };
const AR_CHILD_COLS_PAREN_NODES: &[Node] = const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
&[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_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_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES); 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_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[ const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES)); 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")); 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>, …)` // add_index — `add index [as <name>] on <T> (<col>, …)`
// ================================================================= // =================================================================
const AI_AS_NAME_NODES: &[Node] = &[ const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES)); const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[ const ADD_INDEX_NODES: &[Node] = &[
@@ -537,9 +528,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const NEW_COLUMN_NAME: Node = Node::Hinted { const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -563,10 +554,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
// ( <type> ) [--force-conversion | --dont-convert]` // ( <type> ) [--force-conversion | --dont-convert]`
// ================================================================= // =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[ const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_OPT: Node = Node::Repeated { const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES), inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None, separator: None,
@@ -732,8 +720,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())], args: vec![("detail", "unknown type".to_string())],
})?; })?;
let (not_null, unique, default, check) = let (not_null, unique, default, check) = collect_column_constraints(path)?;
collect_column_constraints(path)?;
Ok(Command::AddColumn { Ok(Command::AddColumn {
table: require_ident(path, "table_name")?, table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?, column: require_ident(path, "column_name")?,
@@ -949,7 +936,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
} else { } else {
return Err(ValidationError { return Err(ValidationError {
message_key: "parse.error_wrapper", 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 { Ok(Command::DropConstraint {
@@ -981,7 +971,8 @@ pub static DROP: CommandNode = CommandNode {
"parse.usage.drop_relationship", "parse.usage.drop_relationship",
"parse.usage.drop_index", "parse.usage.drop_index",
"parse.usage.drop_constraint", "parse.usage.drop_constraint",
],}; ],
};
pub static ADD: CommandNode = CommandNode { pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"), entry: Word::keyword("add"),
@@ -1003,7 +994,8 @@ pub static ADD: CommandNode = CommandNode {
"parse.usage.add_relationship", "parse.usage.add_relationship",
"parse.usage.add_index", "parse.usage.add_index",
"parse.usage.add_constraint", "parse.usage.add_constraint",
],}; ],
};
pub static RENAME: CommandNode = CommandNode { pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"), entry: Word::keyword("rename"),
@@ -1011,7 +1003,8 @@ pub static RENAME: CommandNode = CommandNode {
ast_builder: build_rename_column, ast_builder: build_rename_column,
help_id: Some("ddl.rename"), help_id: Some("ddl.rename"),
hint_ids: &["rename_column"], hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],}; usage_ids: &["parse.usage.rename_column"],
};
pub static CHANGE: CommandNode = CommandNode { pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"), entry: Word::keyword("change"),
@@ -1019,7 +1012,8 @@ pub static CHANGE: CommandNode = CommandNode {
ast_builder: build_change_column, ast_builder: build_change_column,
help_id: Some("ddl.change"), help_id: Some("ddl.change"),
hint_ids: &["change_column"], 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>)[, ...]]]` // create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
@@ -1034,9 +1028,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
const COL_NAME: Node = Node::Hinted { const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT, mode: NEW_NAME_HINT,
@@ -1074,8 +1068,12 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
]; ];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES); const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] = const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT]; NOT_NULL_CONSTRAINT,
UNIQUE_CONSTRAINT,
DEFAULT_CONSTRAINT,
CHECK_CONSTRAINT,
];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES); const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's /// 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_ // `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion // EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns. // candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] = const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES); const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
const ADD_CONSTRAINT_NODES: &[Node] = &[ const ADD_CONSTRAINT_NODES: &[Node] = &[
@@ -1145,9 +1142,9 @@ const COL_SPEC_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
Node::Punct(')'), Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX, 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(); let mut items = path.items.iter().peekable();
while let Some(item) = items.next() { while let Some(item) = items.next() {
match &item.kind { match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => { MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone()); 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 { let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())], args: vec![("detail", "unknown type".to_string())],
@@ -1380,7 +1381,8 @@ pub static CREATE: CommandNode = CommandNode {
ast_builder: build_create_table, ast_builder: build_create_table,
help_id: Some("ddl.create"), help_id: Some("ddl.create"),
hint_ids: &["create_table"], 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>]` // 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() { while let Some(item) = items.next() {
match &item.kind { match &item.kind {
// A column name stashes until its type finalises the spec. // 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()); pending_name = Some(item.text.clone());
} }
// Single-word type — resolve through the SQL alias map. // 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 { let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())], 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; column_open = true;
} }
// A table-level `PRIMARY KEY (col, …)` column reference. // A table-level `PRIMARY KEY (col, …)` column reference.
MatchedKind::Ident { role: "pk_column", .. } => { MatchedKind::Ident {
role: "pk_column", ..
} => {
primary_key.push(item.text.clone()); primary_key.push(item.text.clone());
} }
// `not null` column constraint (only once a column exists; // `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(); let mut cols: Vec<String> = Vec::new();
while let Some(it) = items.peek() { while let Some(it) = items.peek() {
match &it.kind { match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => { MatchedKind::Ident {
role: "unique_column",
..
} => {
cols.push(it.text.clone()); cols.push(it.text.clone());
items.next(); 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 // column's flag (round-trips via the single-column
// path); composite (or a name not among the // path); composite (or a name not among the
// columns) becomes a constraint. // 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, Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols), None if !cols.is_empty() => unique_constraints.push(cols),
None => {} 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 // the most recent column) or the table-level clause (whose
// `pk_column` idents follow and are collected above). // `pk_column` idents follow and are collected above).
MatchedKind::Word("primary") => { 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(); items.next();
// Table-level `PRIMARY KEY (…)` is followed by `(` // Table-level `PRIMARY KEY (…)` is followed by `(`
// (then `pk_column` idents, collected above); // (then `pk_column` idents, collected above);
// column-level `PRIMARY KEY` is not, and marks the // column-level `PRIMARY KEY` is not, and marks the
// most-recent column. // most-recent column.
let table_level = matches!( let table_level =
items.peek().map(|i| &i.kind), matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
Some(MatchedKind::Punct('('))
);
if !table_level && let Some(last) = columns.last() { if !table_level && let Some(last) = columns.last() {
primary_key.push(last.name.clone()); 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); // Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4). // 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()); 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>) // Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b). // references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => { 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` items.next(); // `key`
} }
// `( <child column> [, <child column>]* )` — a compound // `( <child column> [, <child column>]* )` — a compound
@@ -1674,7 +1697,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
items.next(); items.next();
} }
// `references <parent> …` // `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(); items.next();
} }
let fk = let fk =
@@ -1859,13 +1885,19 @@ where
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade, Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict, Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
Some(MatchedKind::Word("set")) => { 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(); items.next();
} }
ReferentialAction::SetNull ReferentialAction::SetNull
} }
Some(MatchedKind::Word("no")) => { 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(); items.next();
} }
ReferentialAction::NoAction ReferentialAction::NoAction
@@ -1933,11 +1965,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
// concrete keyword (`unique index` | `index`) — the trap-safe form (the // concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The // §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`. // builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))]; Node::Word(Word::keyword("unique")),
Node::Word(Word::keyword("index")),
];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES); const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] = static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES); const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[ static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
@@ -2104,8 +2137,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
NEW_COLUMN_NAME, NEW_COLUMN_NAME,
]; ];
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES); const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES); 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]; 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); 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, super::sql_create_table::SQL_TYPE,
]; ];
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES); const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
static AT_AC_NOT_NULL_NODES: &[Node] = static AT_AC_NOT_NULL_NODES: &[Node] = &[
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))]; Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
];
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES); const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[ static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
Node::Word(Word::keyword("data")), 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); 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]; 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); const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
static AT_AC_DROP_TAIL_CHOICES: &[Node] = static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES); 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]; 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); 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(); let mut items = path.items.iter().peekable();
while let Some(item) = items.next() { while let Some(item) = items.next() {
match &item.kind { match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => { MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone()); 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 { let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())], args: vec![("detail", "unknown type".to_string())],
@@ -2280,7 +2317,10 @@ fn build_alter_add_column_spec(
spec = Some(ColumnSpec::new(name, Type::Real)); spec = Some(ColumnSpec::new(name, Type::Real));
} }
MatchedKind::Word("not") => { 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(); items.next();
if let Some(s) = spec.as_mut() { if let Some(s) = spec.as_mut() {
s.not_null = true; 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(); let mut items = path.items.iter().peekable();
while let Some(item) = items.next() { while let Some(item) = items.next() {
match &item.kind { match &item.kind {
MatchedKind::Ident { role: "col_type", .. } => { MatchedKind::Ident {
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError { role: "col_type", ..
message_key: "parse.error_wrapper", } => {
args: vec![("detail", "unknown type".to_string())], 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") => { MatchedKind::Word("double") => {
if matches!( if matches!(
@@ -2379,7 +2423,10 @@ fn build_alter_column_attr(
message_key: "parse.error_wrapper", message_key: "parse.error_wrapper",
args: vec![("detail", "set default needs a value".to_string())], 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 }, (false, true) => AlterTableAction::DropColumnDefault { column },
(true, false) => AlterTableAction::SetColumnNotNull { 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). /// 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 /// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism. /// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql( fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
path: &MatchedPath,
source: &str,
) -> Result<String, ValidationError> {
let mut items = path.items.iter().peekable(); let mut items = path.items.iter().peekable();
while let Some(item) = items.next() { while let Some(item) = items.next() {
if matches!(item.kind, MatchedKind::Word("check")) if matches!(item.kind, MatchedKind::Word("check"))
@@ -2528,7 +2572,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
items.next(); items.next();
} }
items.next(); // `foreign` 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(); items.next();
} }
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) { 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(')'))) { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next(); 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(); items.next();
} }
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form. // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
@@ -2626,7 +2676,10 @@ mod constraint_tests {
fn an_unconstrained_create_table_still_parses() { fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)"); let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2); 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] #[test]
@@ -2651,7 +2704,9 @@ mod constraint_tests {
#[test] #[test]
fn add_column_parses_a_unique_constraint() { fn add_column_parses_a_unique_constraint() {
match parse_command("add column to T: email (text) unique").expect("parse") { 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!(unique);
assert!(!not_null); assert!(!not_null);
} }
@@ -2682,9 +2737,7 @@ mod constraint_tests {
fn check_with_a_parenthesised_sub_expression_parses() { fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the // The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly. // builder's paren-depth scan must pair them correctly.
let cols = create_columns( let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
assert!(cols[0].check.is_some()); assert!(cols[0].check.is_some());
} }
@@ -2731,8 +2784,7 @@ mod constraint_tests {
#[test] #[test]
fn add_constraint_check_parses() { 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 { Command::AddConstraint {
column, constraint, .. column, constraint, ..
} => { } => {
@@ -2826,8 +2878,11 @@ mod sql_drop_table_tests {
Command::DropColumn { .. } Command::DropColumn { .. }
)); ));
assert!(matches!( assert!(matches!(
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced) parse_command_in_mode(
.expect("parses"), "drop relationship Customers_id_to_Orders_CustId",
Mode::Advanced
)
.expect("parses"),
Command::DropRelationship { .. } Command::DropRelationship { .. }
)); ));
} }
@@ -2932,7 +2987,13 @@ mod sql_create_index_tests {
columns, columns,
unique, unique,
if_not_exists, 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:?}"), other => panic!("expected SqlCreateIndex, got {other:?}"),
} }
} }
@@ -3134,7 +3195,9 @@ mod sql_alter_table_tests {
// The target slot carries the `reject_internal_table` validator // The target slot carries the `reject_internal_table` validator
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused // (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
// before submit — engine-neutral, not a raw engine error. // 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] #[test]
@@ -3213,7 +3276,10 @@ mod sql_alter_table_tests {
// alias map still applies through the synonym // alias map still applies through the synonym
assert!(matches!( assert!(matches!(
alter("alter table T alter column n set data type double precision").1, 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] #[test]
fn alter_column_set_default_captures_raw_expr() { fn alter_column_set_default_captures_raw_expr() {
match alter("alter table T alter column qty set default 0").1 { 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!(column, "qty");
assert_eq!(default_sql, "0"); assert_eq!(default_sql, "0");
} }
@@ -3317,7 +3386,9 @@ mod sql_alter_table_tests {
match alter("alter table T add check (a < b)").1 { match alter("alter table T add check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => { AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None); 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:?}"), 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 { match alter("alter table T add unique (a, b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => { AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None); 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:?}"), other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
} }
@@ -3352,7 +3425,9 @@ mod sql_alter_table_tests {
) )
.expect_err("a named UNIQUE constraint is refused"); .expect_err("a named UNIQUE constraint is refused");
assert!( 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}" "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) let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
.expect_err("ADD PRIMARY KEY is refused"); .expect_err("ADD PRIMARY KEY is refused");
assert!( 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}" "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")); assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint { match *constraint {
TableConstraint::ForeignKey(fk) => { 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:?}"), other => panic!("expected ForeignKey, got {other:?}"),
} }
+24 -35
View File
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: true, writes_column: true,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
/// Operand alternatives. The literal keywords (`null` / `true` /// 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 // the leak is per distinct column (the walker
// memoizes `DynamicSubgrammar` resolution on // memoizes `DynamicSubgrammar` resolution on
// `current_column`), not per keystroke. // `current_column`), not per keystroke.
let leaked: &'static str = let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
Box::leak(col.name.clone().into_boxed_str());
Node::TypedValueSlot { Node::TypedValueSlot {
ty: col.user_type, ty: col.user_type,
column_name: Some(leaked), column_name: Some(leaked),
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
Node::Subgrammar(&OR_EXPR), Node::Subgrammar(&OR_EXPR),
Node::Punct(')'), Node::Punct(')'),
]; ];
static BOOL_PRIMARY_CHOICES: &[Node] = &[ static BOOL_PRIMARY_CHOICES: &[Node] =
Node::Seq(PAREN_GROUP_NODES), &[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
Node::Subgrammar(&PREDICATE),
];
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES); static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
/// `not_expr := NOT not_expr | bool_primary`. /// `not_expr := NOT not_expr | bool_primary`.
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")), Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR), Node::Subgrammar(&NOT_EXPR),
]; ];
static NOT_EXPR_CHOICES: &[Node] = &[ static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&BOOL_PRIMARY),
];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES); static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
/// `and_expr := not_expr ( AND not_expr )*`. /// `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 /// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
/// point. `update` / `delete` / `show data` reference this /// point. `update` / `delete` / `show data` reference this
/// through `Node::Subgrammar(&OR_EXPR)`. /// through `Node::Subgrammar(&OR_EXPR)`.
static OR_TAIL_NODES: &[Node] = &[ static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES); static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static OR_EXPR_NODES: &[Node] = &[ static OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR), Node::Subgrammar(&AND_EXPR),
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
let span = item.span; let span = item.span;
let literal = |value: Value| Operand::Literal { value, span }; let literal = |value: Value| Operand::Literal { value, span };
match &item.kind { match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => { MatchedKind::Ident {
Ok(Operand::Column { name: item.text.clone(), span }) role: "expr_column",
} ..
} => Ok(Operand::Column {
name: item.text.clone(),
span,
}),
MatchedKind::Word("null") => Ok(literal(Value::Null)), MatchedKind::Word("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))), MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))), MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => { MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
Ok(literal(Value::Number(item.text.clone()))) MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
}
MatchedKind::StringLit => {
Ok(literal(Value::Text(item.text.clone())))
}
_ => Err(drift_error("expected a column or literal operand")), _ => Err(drift_error("expected a column or literal operand")),
} }
} }
@@ -591,8 +582,7 @@ mod tests {
let mut ctx = WalkContext::new(); let mut ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::new(); let mut per_byte = Vec::new();
let result = let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
match result { match result {
NodeWalkResult::Matched { end, .. } => { NodeWalkResult::Matched { end, .. } => {
assert!( assert!(
@@ -730,8 +720,7 @@ mod tests {
negated: false, negated: false,
}), }),
); );
let Expr::Predicate(Predicate::Like { negated, .. }) = let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
parse_expr("Name not like 'A%'")
else { else {
panic!("expected a negated Like"); panic!("expected a negated Like");
}; };
@@ -794,16 +783,16 @@ mod tests {
fn nested_parentheses_round_trip() { fn nested_parentheses_round_trip() {
// Exercises the Subgrammar recursion a few levels deep. // Exercises the Subgrammar recursion a few levels deep.
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))"); 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] #[test]
fn case_insensitive_keywords() { fn case_insensitive_keywords() {
// Keywords fold case; the built tree is identical. // Keywords fold case; the built tree is identical.
assert_eq!( assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
parse_expr("a = 1 AND b = 2"),
parse_expr("a = 1 and b = 2"),
);
assert_eq!( assert_eq!(
parse_expr("Email IS NOT NULL"), parse_expr("Email IS NOT NULL"),
parse_expr("Email is not null"), parse_expr("Email is not null"),
+20 -24
View File
@@ -27,9 +27,9 @@ pub mod data;
pub mod ddl; pub mod ddl;
pub mod expr; pub mod expr;
pub mod shared; pub mod shared;
pub mod sql_expr;
pub mod sql_create_table; pub mod sql_create_table;
pub mod sql_delete; pub mod sql_delete;
pub mod sql_expr;
pub mod sql_insert; pub mod sql_insert;
pub mod sql_select; pub mod sql_select;
pub mod sql_update; pub mod sql_update;
@@ -328,9 +328,7 @@ pub enum Node {
/// A number literal. The optional `validator` runs against /// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce /// the matched text (used by Phase D value slots to enforce
/// per-type integer/decimal rules). /// per-type integer/decimal rules).
NumberLit { NumberLit { validator: Option<NumberValidator> },
validator: Option<NumberValidator>,
},
/// A literal byte sequence at this position — matches /// A literal byte sequence at this position — matches
/// bytes verbatim (whitespace-skipped) with a lookahead so /// bytes verbatim (whitespace-skipped) with a lookahead so
/// `1` doesn't half-match `12` and `n` doesn't half-match /// `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) .filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect() .collect()
}; };
if selected.is_empty() { candidates } else { selected } if selected.is_empty() {
candidates
} else {
selected
}
} }
/// The single usage template most relevant to `source`, when /// 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 /// disambiguates the single most-relevant usage key from the
/// mode-selected key set. /// mode-selected key set.
#[must_use] #[must_use]
pub fn usage_key_for_input_in_mode( pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?; let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys) 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` // The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it; its key ends `…m2n`. // — 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")); return keys.iter().copied().find(|k| k.ends_with("m2n"));
} }
// Otherwise the form word is an identifier — `column`, `index`, // 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`. /// which read the same data through the legacy `usage::REGISTRY`.
#[must_use] #[must_use]
pub fn entry_words_alphabetised() -> Vec<&'static str> { pub fn entry_words_alphabetised() -> Vec<&'static str> {
let mut words: Vec<&'static str> = let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
words.sort_unstable(); words.sort_unstable();
words.dedup(); words.dedup();
words words
@@ -905,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 /// returns its `Simple` DSL node and `Advanced` SQL node. The
/// dispatcher picks among them by the active input mode. /// dispatcher picks among them by the active input mode.
#[must_use] #[must_use]
pub fn commands_for_entry_word( pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
word: &str,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
REGISTRY REGISTRY
.iter() .iter()
.enumerate() .enumerate()
@@ -1010,7 +1009,10 @@ mod hint_key_tests {
]; ];
for c in classes { for c in classes {
let key = format!("hint.err.{c}.what"); 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}`"
);
} }
} }
@@ -1098,10 +1100,7 @@ mod usage_key_tests {
let cases = [ let cases = [
("add column to T: c (int)", "parse.usage.add_column"), ("add column to T: c (int)", "parse.usage.add_column"),
("add index on T (c)", "parse.usage.add_index"), ("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", "drop constraint check from T.c",
"parse.usage.drop_constraint", "parse.usage.drop_constraint",
@@ -1118,10 +1117,7 @@ mod usage_key_tests {
("drop table T", "parse.usage.drop_table"), ("drop table T", "parse.usage.drop_table"),
("drop column from table T: c", "parse.usage.drop_column"), ("drop column from table T: c", "parse.usage.drop_column"),
("drop index i", "parse.usage.drop_index"), ("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 data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"), ("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed // `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::completion::TableColumn;
use crate::dsl::grammar::{ use crate::dsl::grammar::{
HighlightClass, HintMode, IdentSource, IdentValidator, Node, HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
NumberValidator, ValidationError, Word, Word,
}; };
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::context::WalkContext;
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
.join(", "); .join(", ");
Err(ValidationError { Err(ValidationError {
message_key: "parse.custom.unknown_type", message_key: "parse.custom.unknown_type",
args: vec![ args: vec![("found", value.to_string()), ("expected", expected)],
("found", value.to_string()),
("expected", expected),
],
}) })
} }
} }
@@ -51,12 +48,12 @@ pub const TYPE_SLOT: Node = Node::Ident {
role: "type", role: "type",
validator: Some(TYPE_VALIDATOR), validator: Some(TYPE_VALIDATOR),
highlight_override: Some(HighlightClass::Type), highlight_override: Some(HighlightClass::Type),
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
// --- Qualified column reference (`<Table>.<Column>`) -------------- // --- Qualified column reference (`<Table>.<Column>`) --------------
@@ -70,9 +67,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
Node::Punct('.'), Node::Punct('.'),
Node::Ident { Node::Ident {
@@ -83,9 +80,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}, },
]; ];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES); 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::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER, Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER, Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => { Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER,
&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`] /// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which /// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17). /// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>( pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
ctx: &'c WalkContext<'_>,
) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?; let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() { if table_cols.is_empty() {
return None; 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 // / `foreign`) that disambiguates it from a column name. (A column
// literally named with one of those keywords is therefore unavailable, // literally named with one of those keywords is therefore unavailable,
// the same trade real SQL makes with its reserved words.) // the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] = static ELEMENT_CHOICES: &[Node] = &[
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF]; TABLE_PK,
TABLE_UNIQUE,
TABLE_CHECK,
TABLE_FK_NAMED,
TABLE_FK,
COLUMN_DEF,
];
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES); const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
// Issue #4: wrap the element slot in `IntroProse` so a fresh element // Issue #4: wrap the element slot in `IntroProse` so a fresh element
// position (`create table T (` and after every `,`) surfaces a prose // position (`create table T (` and after every `,`) surfaces a prose
@@ -495,18 +501,31 @@ mod tests {
let mut ctx = WalkContext::new(); let mut ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::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(), NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false, _ => false,
} }
} }
fn good(input: &str) { 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) { 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] #[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(id))");
good("table t (id int, ref int references other)"); // bare ref 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 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, foreign key (ref) references other(id))");
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))"); good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
good( good(
@@ -691,7 +712,10 @@ mod builder_tests {
assert_eq!(name, "t"); assert_eq!(name, "t");
assert_eq!( assert_eq!(
cols, 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!(pk.is_empty(), "no PK declared");
assert!(!ine); assert!(!ine);
@@ -740,7 +764,10 @@ mod builder_tests {
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))"); let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
assert_eq!( assert_eq!(
cols, 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() { fn redundant_constraints_deduped_off_sole_pk_column() {
// ADR-0035 §6.5: advanced mode accepts the redundant spelling // ADR-0035 §6.5: advanced mode accepts the redundant spelling
// and silently drops the flags off the sole PK column. // and silently drops the flags off the sole PK column.
match parse_command("create table t (id int primary key not null unique)") match parse_command("create table t (id int primary key not null unique)").expect("parses")
.expect("parses")
{ {
Command::SqlCreateTable { Command::SqlCreateTable {
columns, columns,
@@ -944,8 +970,7 @@ mod builder_tests {
// depth 2, not an element boundary, so the following `check` // depth 2, not an element boundary, so the following `check`
// is still column-level. A naive "reset on any comma" would // is still column-level. A naive "reset on any comma" would
// misclassify it as table-level (the §4.2 probe). // misclassify it as table-level (the §4.2 probe).
let (cols, checks) = let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
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_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
assert!(checks.is_empty(), "no table-level CHECK was produced"); 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() { fn table_check_before_a_later_column_is_table_level() {
// A CHECK element that appears between columns (not after a // A CHECK element that appears between columns (not after a
// column's type) is table-level even though more columns follow. // column's type) is table-level even though more columns follow.
let (cols, checks) = let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
parse_sct_checks("create table t (a int, check (a > 0), b int)");
assert_eq!(checks, vec!["a > 0".to_string()]); assert_eq!(checks, vec!["a > 0".to_string()]);
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none()); 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.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction); assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, 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] #[test]
@@ -1012,14 +1039,19 @@ mod builder_tests {
// The table-level `FOREIGN KEY (...)` form is not inline, so it can // The table-level `FOREIGN KEY (...)` form is not inline, so it can
// carry a multi-column reference and never triggers the inline // carry a multi-column reference and never triggers the inline
// "use the table-level form" hint (ADR-0043 D4). // "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"); assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
} }
#[test] #[test]
fn bare_inline_reference_has_no_parent_column() { fn bare_inline_reference_has_no_parent_column() {
let fks = parse_sct_fks("create table t (id int, pid int references parent)"); 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].parent_table, "parent");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]); assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
} }
@@ -1047,8 +1079,9 @@ mod builder_tests {
#[test] #[test]
fn table_level_foreign_key_captured() { fn table_level_foreign_key_captured() {
let fks = let fks = parse_sct_fks(
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))"); "create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert_eq!(fks.len(), 1); assert_eq!(fks.len(), 1);
assert_eq!(fks[0].name, None); assert_eq!(fks[0].name, None);
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]); 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))", foreign key (a) references p(id), foreign key (b) references q(id))",
); );
assert_eq!(fks.len(), 2); assert_eq!(fks.len(), 2);
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p")); assert_eq!(
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q")); (
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] #[test]
@@ -1108,7 +1153,12 @@ mod builder_tests {
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]); assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
// the column-level CHECK still attaches to `pid` // the column-level CHECK still attaches to `pid`
assert_eq!( 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") Some("pid > 0")
); );
// the table-level CHECK is captured separately // the table-level CHECK is captured separately
+12 -2
View File
@@ -82,7 +82,14 @@ mod tests {
let mut ctx = WalkContext::new(); let mut ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::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(), NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false, _ => false,
} }
@@ -93,7 +100,10 @@ mod tests {
} }
fn bad(input: &str) { 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] #[test]
+31 -63
View File
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
// ================================================================= // =================================================================
// or_expr := and_expr ( OR and_expr )* — the fragment entry point // or_expr := and_expr ( OR and_expr )* — the fragment entry point
// ================================================================= // =================================================================
static OR_TAIL_NODES: &[Node] = &[ static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES); static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static SQL_OR_EXPR_NODES: &[Node] = &[ static SQL_OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR), Node::Subgrammar(&AND_EXPR),
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")), Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR), Node::Subgrammar(&NOT_EXPR),
]; ];
static NOT_EXPR_CHOICES: &[Node] = &[ static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&PREDICATE),
];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES); 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 // needs. ADR-0026's DSL grammar made the tail mandatory because it
// forbade a bare column as a boolean; SQL does not. // forbade a bare column as a boolean; SQL does not.
static PREDICATE_NODES: &[Node] = &[ static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
Node::Subgrammar(&ADDITIVE),
Node::Optional(&PREDICATE_TAIL),
];
static PREDICATE: Node = Node::Seq(PREDICATE_NODES); static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
// ---- cmp_op := <= | <> | >= | != | < | > | = -------------------- // ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
// ---- predicate_tail branches ------------------------------------ // ---- predicate_tail branches ------------------------------------
/// `cmp_op additive`. /// `cmp_op additive`.
static COMPARE_FORM_NODES: &[Node] = &[ static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
Node::Choice(CMP_OP_CHOICES),
Node::Subgrammar(&ADDITIVE),
];
/// `IS [NOT] NULL`. /// `IS [NOT] NULL`.
static IS_NULL_NODES: &[Node] = &[ static IS_NULL_NODES: &[Node] = &[
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
// additive := multiplicative ( ( + | - | || ) multiplicative )* // additive := multiplicative ( ( + | - | || ) multiplicative )*
// ================================================================= // =================================================================
static ADD_OP_CHOICES: &[Node] = &[ static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
Node::Punct('+'),
Node::Punct('-'),
Node::Literal("||"),
];
static ADD_TAIL_NODES: &[Node] = &[ static ADD_TAIL_NODES: &[Node] = &[
Node::Choice(ADD_OP_CHOICES), Node::Choice(ADD_OP_CHOICES),
Node::Subgrammar(&MULTIPLICATIVE), Node::Subgrammar(&MULTIPLICATIVE),
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
// multiplicative := unary ( ( * | / | % ) unary )* // multiplicative := unary ( ( * | / | % ) unary )*
// ================================================================= // =================================================================
static MUL_OP_CHOICES: &[Node] = &[ static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
Node::Punct('*'), static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
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 MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
static MULTIPLICATIVE_NODES: &[Node] = &[ static MULTIPLICATIVE_NODES: &[Node] = &[
Node::Subgrammar(&UNARY), Node::Subgrammar(&UNARY),
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
// ================================================================= // =================================================================
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')]; static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
static UNARY_SIGN_NODES: &[Node] = &[ static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
Node::Choice(SIGN_CHOICES), static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
Node::Subgrammar(&UNARY),
];
static UNARY_CHOICES: &[Node] = &[
Node::Seq(UNARY_SIGN_NODES),
Node::Subgrammar(&PRIMARY),
];
static UNARY: Node = Node::Choice(UNARY_CHOICES); static UNARY: Node = Node::Choice(UNARY_CHOICES);
// ================================================================= // =================================================================
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
Node::Optional(&ELSE_CLAUSE), Node::Optional(&ELSE_CLAUSE),
Node::Word(Word::keyword("end")), Node::Word(Word::keyword("end")),
]; ];
static CASE_BODY_CHOICES: &[Node] = &[ static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
Node::Seq(SEARCHED_CASE_NODES),
Node::Seq(SIMPLE_CASE_NODES),
];
static CASE_NODES: &[Node] = &[ static CASE_NODES: &[Node] = &[
Node::Word(Word::keyword("case")), Node::Word(Word::keyword("case")),
Node::Choice(CASE_BODY_CHOICES), Node::Choice(CASE_BODY_CHOICES),
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[ static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
Node::Punct('.'),
QUALIFIED_REF_IDENT,
];
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[ static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
Node::Seq(QUALIFIED_REF_TAIL_NODES), Node::Seq(QUALIFIED_REF_TAIL_NODES),
@@ -531,7 +496,10 @@ mod tests {
/// Assert `input` is *not* a complete SQL expression. /// Assert `input` is *not* a complete SQL expression.
fn bad(input: &str) { 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] #[test]
@@ -643,13 +611,13 @@ mod tests {
#[test] #[test]
fn malformed_expressions_do_not_walk() { fn malformed_expressions_do_not_walk() {
bad("a +"); // dangling operator bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand bad("= 1"); // no left operand
bad("a = "); // no right operand bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective bad("and b"); // leading connective
bad("upper("); // unclosed call bad("upper("); // unclosed call
} }
#[test] #[test]
@@ -680,9 +648,9 @@ mod tests {
// The optional tail dispatches `.identifier` (qualified // The optional tail dispatches `.identifier` (qualified
// ref) vs `(args)` (function call) by first token — a // ref) vs `(args)` (function call) by first token — a
// bare ident remains a column ref. // bare ident remains a column ref.
good("foo(x)"); // function call good("foo(x)"); // function call
good("foo.bar"); // qualified ref good("foo.bar"); // qualified ref
good("foo"); // bare ref good("foo"); // bare ref
} }
#[test] #[test]
+31 -8
View File
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
listed listed
.iter() .iter()
.filter_map(|name| { .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() .collect()
}, },
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node { fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
let cols = target_value_columns(ctx); let cols = target_value_columns(ctx);
let (count, closed) = count_tuple_values(source, pos); 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 { if !cols.is_empty() && arity_ok {
Node::DynamicSubgrammar(sql_value_list) Node::DynamicSubgrammar(sql_value_list)
} else { } else {
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
/// the enclosing Seq, each branch's FIRST token (`nothing` vs /// the enclosing Seq, each branch's FIRST token (`nothing` vs
/// `update`) disambiguates, so a non-match of branch 0 is a clean /// `update`) disambiguates, so a non-match of branch 0 is a clean
/// `NoMatch` that falls through to branch 1. /// `NoMatch` that falls through to branch 1.
static DO_ACTION_CHOICES: &[Node] = static DO_ACTION_CHOICES: &[Node] = &[
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)]; Node::Word(Word::keyword("nothing")),
Node::Seq(DO_UPDATE_NODES),
];
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`. // `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES); const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
@@ -361,7 +370,14 @@ mod tests {
let mut ctx = WalkContext::new(); let mut ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::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(), NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false, _ => false,
} }
@@ -372,7 +388,10 @@ mod tests {
} }
fn bad(input: &str) { 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] #[test]
@@ -418,8 +437,12 @@ mod tests {
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9). // 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 (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 do nothing");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name"); good(
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0"); "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. // 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"); 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, // 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 /// suffix keywords. `as` is not listed — the AS-form alias is a
/// separate `Choice` branch that fires before the lookahead. /// separate `Choice` branch that fires before the lookahead.
const PROJECTION_FOLLOW_SET: &[&str] = &[ const PROJECTION_FOLLOW_SET: &[&str] = &[
"from", "where", "group", "order", "having", "limit", "from",
"union", "intersect", "except", "where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
// `returning` belongs to an enclosing DML statement // `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a // (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
// projection item's bare alias — so a no-FROM SELECT row source // 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 /// only when `b` has no alias — `on` is not a base-table name a
/// learner would type as an alias. /// learner would type as an alias.
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[ const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
"where", "group", "order", "having", "limit", "where",
"union", "intersect", "except", "group",
"inner", "left", "right", "full", "cross", "join", "on", "order",
"having",
"limit",
"union",
"intersect",
"except",
"inner",
"left",
"right",
"full",
"cross",
"join",
"on",
// `returning` belongs to an enclosing DML statement // `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the // (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
// SELECT row source must not read it as table `t`'s bare alias. // 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()) consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
} }
fn projection_bare_alias_factory( fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
match peek_next_ident_lower(source, pos) { match peek_next_ident_lower(source, pos) {
Some(word) Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Node::Subgrammar(&EMPTY_NOMATCH) Node::Subgrammar(&EMPTY_NOMATCH)
} }
Some(_) => PROJECTION_BARE_ALIAS_IDENT, Some(_) => PROJECTION_BARE_ALIAS_IDENT,
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
} }
} }
fn table_source_bare_alias_factory( fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
match peek_next_ident_lower(source, pos) { match peek_next_ident_lower(source, pos) {
Some(word) Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Node::Subgrammar(&EMPTY_NOMATCH) Node::Subgrammar(&EMPTY_NOMATCH)
} }
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT, Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: true, writes_table_alias: true,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[ static PROJECTION_AS_ALIAS_NODES: &[Node] =
Node::Word(Word::keyword("as")), &[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
PROJECTION_BARE_ALIAS_IDENT,
];
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES); static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[ static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
Node::Lookahead(projection_bare_alias_factory), Node::Lookahead(projection_bare_alias_factory),
]; ];
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES); static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
static PROJECTION_ALIAS_OPTIONAL: Node = static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
Node::Optional(&PROJECTION_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[ static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS), Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
Node::Lookahead(table_source_bare_alias_factory), Node::Lookahead(table_source_bare_alias_factory),
]; ];
static TABLE_SOURCE_ALIAS_CHOICE: Node = static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES); static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
// ================================================================= // =================================================================
// Projection item // Projection item
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
static QUALIFIED_STAR_NODES: &[Node] = &[ static QUALIFIED_STAR_NODES: &[Node] =
QUALIFIED_STAR_QUALIFIER, &[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
Node::Punct('.'),
Node::Punct('*'),
];
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES); static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[ 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 /// ambiguity between `t.*` and `sql_expr` (which can match a
/// bare `t`), since the walker's `Choice` doesn't backtrack on /// bare `t`), since the walker's `Choice` doesn't backtrack on
/// a committed match. /// a committed match.
fn projection_item_factory( fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
let p = skip_whitespace(source, pos); let p = skip_whitespace(source, pos);
let bytes = source.as_bytes(); let bytes = source.as_bytes();
if bytes.get(p) == Some(&b'*') { if bytes.get(p) == Some(&b'*') {
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
Node::Word(Word::keyword("all")), Node::Word(Word::keyword("all")),
]; ];
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES); static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
static DISTINCT_OR_ALL_OPTIONAL: Node = static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
// ================================================================= // =================================================================
// Table source (FROM / JOIN target) // Table source (FROM / JOIN target)
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
static TABLE_SOURCE_NODES: &[Node] = &[ 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 JOIN_WORD: Node = Node::Word(Word::keyword("join"));
const ON_WORD: Node = Node::Word(Word::keyword("on")); const ON_WORD: Node = Node::Word(Word::keyword("on"));
static OUTER_OPTIONAL: Node = static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
Node::Optional(&Node::Word(Word::keyword("outer")));
// `INNER JOIN` and bare `JOIN` are split into two Choice // `INNER JOIN` and bare `JOIN` are split into two Choice
// branches so each branch has a distinct leading keyword // 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: Node = Node::Choice(SET_OP_CHOICES);
static SET_OP_TAIL_NODES: &[Node] = static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES); static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
static PLAIN_COMPOUND_NODES: &[Node] = &[ static PLAIN_COMPOUND_NODES: &[Node] = &[
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
Node::Subgrammar(&WITH_CLAUSE), Node::Subgrammar(&WITH_CLAUSE),
Node::Subgrammar(&PLAIN_COMPOUND), Node::Subgrammar(&PLAIN_COMPOUND),
]; ];
static WITH_PREFIXED_COMPOUND: Node = static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static COMPOUND_CHOICES: &[Node] = &[ static COMPOUND_CHOICES: &[Node] = &[
Node::Subgrammar(&WITH_PREFIXED_COMPOUND), Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
writes_table: false, writes_table: false,
writes_column: false, writes_column: false,
writes_user_listed_column: false, writes_user_listed_column: false,
writes_table_alias: false, writes_table_alias: false,
writes_cte_name: false, writes_cte_name: false,
writes_projection_alias: false, writes_projection_alias: false,
}; };
static CTE_COLUMN_LIST_NODES: &[Node] = &[ static CTE_COLUMN_LIST_NODES: &[Node] = &[
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
RPAREN, RPAREN,
]; ];
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES); static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
static CTE_COLUMN_LIST_OPTIONAL: Node = static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
Node::Optional(&CTE_COLUMN_LIST_SEQ);
// CTE body recursion pushes a fresh lexical scope frame (ADR- // CTE body recursion pushes a fresh lexical scope frame (ADR-
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same; // 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
// the top-level statement's own COMPOUND embedding does not // the top-level statement's own COMPOUND embedding does not
// (it shares the implicit bottom frame). // (it shares the implicit bottom frame).
static CTE_BODY_NODES: &[Node] = &[ static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
LPAREN,
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
RPAREN,
];
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES); static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
static CTE_DEF_NODES: &[Node] = &[ static CTE_DEF_NODES: &[Node] = &[
@@ -807,9 +793,7 @@ mod tests {
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::new(); let mut per_byte = Vec::new();
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) { match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
NodeWalkResult::Matched { end, .. } => { NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
input[end..].trim().is_empty()
}
_ => false, _ => false,
} }
} }
@@ -819,10 +803,7 @@ mod tests {
} }
fn good(input: &str) { fn good(input: &str) {
assert!( assert!(walks(input), "{input:?} should be a valid SELECT statement");
walks(input),
"{input:?} should be a valid SELECT statement"
);
} }
fn bad(input: &str) { fn bad(input: &str) {
@@ -1051,16 +1032,12 @@ mod tests {
#[test] #[test]
fn set_op_chain() { fn set_op_chain() {
good( good("select a from t union select b from u intersect select c from v");
"select a from t union select b from u intersect select c from v",
);
} }
#[test] #[test]
fn set_op_with_outer_order_by_and_limit() { fn set_op_with_outer_order_by_and_limit() {
good( good("select a from t union select b from u order by a limit 10");
"select a from t union select b from u order by a limit 10",
);
} }
// ----- ORDER BY / LIMIT / OFFSET ----- // ----- ORDER BY / LIMIT / OFFSET -----
@@ -1126,16 +1103,12 @@ mod tests {
#[test] #[test]
fn recursive_cte() { fn recursive_cte() {
good( good("with recursive r as (select 1 union all select 2) select * from r");
"with recursive r as (select 1 union all select 2) select * from r",
);
} }
#[test] #[test]
fn multiple_ctes() { fn multiple_ctes() {
good( good("with a as (select 1), b as (select 2) select * from a union select * from b");
"with a as (select 1), b as (select 2) select * from a union select * from b",
);
} }
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) ----- // ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
@@ -1147,9 +1120,7 @@ mod tests {
#[test] #[test]
fn nested_cte_body_with_union() { fn nested_cte_body_with_union() {
good( good("with x as (select 1 union select 2) select * from x");
"with x as (select 1 union select 2) select * from x",
);
} }
// ----- case insensitivity / spacing ----- // ----- case insensitivity / spacing -----
@@ -1363,9 +1334,7 @@ mod tests {
#[test] #[test]
fn in_subquery_in_where_clause() { fn in_subquery_in_where_clause() {
good("select * from t where id in (select user_id from orders)"); good("select * from t where id in (select user_id from orders)");
good( good("select * from customers where id not in (select customer_id from blocklist)");
"select * from customers where id not in (select customer_id from blocklist)",
);
} }
#[test] #[test]
@@ -1378,9 +1347,7 @@ mod tests {
#[test] #[test]
fn nested_subqueries() { fn nested_subqueries() {
good( good("select * from t where x in (select y from u where y in (select z from v))");
"select * from t where x in (select y from u where y in (select z from v))",
);
} }
#[test] #[test]
@@ -1393,8 +1360,6 @@ mod tests {
#[test] #[test]
fn cte_body_references_qualified_columns() { fn cte_body_references_qualified_columns() {
good( good("with x as (select t.name, t.age from t) select x.name from x");
"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 ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::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(), NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false, _ => false,
} }
@@ -130,7 +137,10 @@ mod tests {
} }
fn bad(input: &str) { 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] #[test]
+3 -3
View File
@@ -21,9 +21,9 @@ pub mod walker;
pub use action::ReferentialAction; pub use action::ReferentialAction;
pub use command::{ pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr, AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter, Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
ShowListKind, SqlForeignKey, RowFilter, ShowListKind, SqlForeignKey,
}; };
pub use parser::{ParseError, parse_command}; pub use parser::{ParseError, parse_command};
pub use types::Type; pub use types::Type;
+18 -25
View File
@@ -55,10 +55,9 @@ pub enum ParseError {
impl std::fmt::Display for ParseError { impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!( Self::Invalid { message, .. } => {
"parse.error_wrapper", f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
detail = message, }
)),
Self::Empty => f.write_str(&crate::t!("parse.empty")), 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` /// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the /// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them. /// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode( pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
input: &str,
mode: Mode,
) -> Result<Command, ParseError> {
parse_command_inner(input, None, mode) parse_command_inner(input, None, mode)
} }
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
.collect(); .collect();
let joined = oxford_join(&entries); let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0); let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else( let (position, found_word) = consume_ident(source, start)
|| (start, None), .map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
|(s, e)| (s, Some(&source[s..e])),
);
let message = found_word.map_or_else( let message = found_word.map_or_else(
|| format!("expected one of {joined}"), || format!("expected one of {joined}"),
|w| format!("expected one of {joined}, found `{w}`"), |w| format!("expected one of {joined}, found `{w}`"),
@@ -1034,19 +1028,22 @@ mod tests {
false, false,
); );
assert_eq!( 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 expected
); );
assert_eq!( 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 expected
); );
} }
#[test] #[test]
fn add_relationship_repeated_clause_errors() { fn add_relationship_repeated_clause_errors() {
let e = let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e { match e {
ParseError::Invalid { message, .. } => { ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}"); assert!(message.contains("specified twice"), "{message}");
@@ -1073,7 +1070,9 @@ mod tests {
#[test] #[test]
fn add_relationship_with_name_actions_and_flag() { fn add_relationship_with_name_actions_and_flag() {
assert_eq!( 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( rel(
Some("cust_orders"), Some("cust_orders"),
("Customers", "Id"), ("Customers", "Id"),
@@ -1300,10 +1299,7 @@ mod tests {
#[test] #[test]
fn advanced_ambiguous_update_routes_to_sql() { fn advanced_ambiguous_update_routes_to_sql() {
assert!(matches!( assert!(matches!(
parse_command_in_mode( parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
"update Orders set total = 0 where id = 1",
Mode::Advanced,
),
Ok(Command::SqlUpdate { .. }) Ok(Command::SqlUpdate { .. })
)); ));
} }
@@ -1399,10 +1395,7 @@ mod tests {
// in advanced mode)" pointer is added at the hint layer // in advanced mode)" pointer is added at the hint layer
// (input_render), not in the parsed command/error here. // (input_render), not in the parsed command/error here.
assert!(matches!( assert!(matches!(
parse_command_in_mode( parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
"delete from Orders where id = 1 returning *",
Mode::Simple,
),
Err(ParseError::Invalid { .. }) Err(ParseError::Invalid { .. })
)); ));
} }
+1 -2
View File
@@ -9,8 +9,7 @@ use rand::RngExt;
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded /// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
/// because they are easily confused in print. /// because they are easily confused in print.
const ALPHABET: &[u8; 58] = const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const DEFAULT_LEN: usize = 10; const DEFAULT_LEN: usize = 10;
+4 -26
View File
@@ -43,29 +43,9 @@
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`, /// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`. /// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
"abs", "abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
"avg", "lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
"coalesce", "trim", "typeof", "upper",
"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 /// Whether `partial` is a case-insensitive prefix of at least one
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
#[must_use] #[must_use]
pub fn is_known_function_prefix(partial: &str) -> bool { pub fn is_known_function_prefix(partial: &str) -> bool {
let lowered = partial.to_lowercase(); let lowered = partial.to_lowercase();
KNOWN_SQL_FUNCTIONS KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
.iter()
.any(|f| f.starts_with(&lowered))
} }
#[cfg(test)] #[cfg(test)]
+2 -9
View File
@@ -59,11 +59,7 @@ impl Type {
#[must_use] #[must_use]
pub const fn sqlite_strict_type(self) -> &'static str { pub const fn sqlite_strict_type(self) -> &'static str {
match self { match self {
Self::Text Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER", Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL", Self::Real => "REAL",
Self::Blob => "BLOB", Self::Blob => "BLOB",
@@ -107,10 +103,7 @@ impl Type {
/// match against a numeric column (ADR-0027, Amendment 1). /// match against a numeric column (ADR-0027, Amendment 1).
#[must_use] #[must_use]
pub const fn is_numeric(self) -> bool { pub const fn is_numeric(self) -> bool {
matches!( matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
} }
/// The user-facing type that an FK column should use to /// 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> { fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
match self { match self {
Self::Number(n) => n Self::Number(n) => {
.parse::<i64>() n.parse::<i64>()
.map(Bound::Integer) .map(Bound::Integer)
.map_err(|_| ValueError::Format { .map_err(|_| ValueError::Format {
column: column.to_string(), column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"), message: format!("`{n}` is not a valid {ty} (whole number expected)"),
}), })
}
other => Err(ValueError::TypeMismatch { other => Err(ValueError::TypeMismatch {
column: column.to_string(), column: column.to_string(),
expected_human: format!("a whole number for `{ty}`"), 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. // Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
let bytes = s.as_bytes(); let bytes = s.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return Err(format!( return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
"`{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 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"))?; 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)?; validate_date(date_part)?;
let bytes = s.as_bytes(); let bytes = s.as_bytes();
if bytes[10] != b'T' { 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':' { if bytes[13] != b':' || bytes[16] != b':' {
return Err(format!("`{s}`: time portion must be `HH:MM:SS`")); return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
@@ -326,8 +327,14 @@ mod tests {
#[test] #[test]
fn integer_for_int_column() { fn integer_for_int_column() {
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42)); assert_eq!(
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7)); 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] #[test]
@@ -355,7 +362,9 @@ mod tests {
#[test] #[test]
fn shortid_validation_runs_on_text_for_shortid_column() { 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 { .. })); assert!(matches!(err, ValueError::Format { .. }));
// Well-formed shortid binds fine. // Well-formed shortid binds fine.
@@ -367,8 +376,14 @@ mod tests {
#[test] #[test]
fn bool_for_bool_column_maps_to_zero_or_one() { 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!(
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0)); 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] #[test]
@@ -377,13 +392,17 @@ mod tests {
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(), t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
Bound::Text("2025-01-15".to_string()) 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 { .. })); assert!(matches!(err, ValueError::Format { .. }));
} }
#[test] #[test]
fn date_range_check() { 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"))); 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::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{ use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
consume_string_literal, skip_whitespace, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
}; };
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1). /// 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`, /// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
/// reusing a previously-leaked Node when the factory's inputs /// reusing a previously-leaked Node when the factory's inputs
/// match a cached entry. /// match a cached entry.
fn resolve_dynamic( fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
factory: fn(&WalkContext) -> Node,
ctx: &WalkContext,
) -> &'static Node {
let key = DynamicKey { let key = DynamicKey {
factory: factory as usize, factory: factory as usize,
current_table_columns: ctx.current_table_columns.clone(), current_table_columns: ctx.current_table_columns.clone(),
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
expected: Vec<Expectation>, expected: Vec<Expectation>,
}, },
/// Committed and hit a hard mismatch or validator failure. /// Committed and hit a hard mismatch or validator failure.
Failed { Failed { position: usize, kind: FailureKind },
position: usize,
kind: FailureKind,
},
} }
const fn matched(end: usize) -> NodeWalkResult { const fn matched(end: usize) -> NodeWalkResult {
@@ -218,9 +210,7 @@ fn walk_node_inner(
kind: FailureKind::Mismatch { expected: vec![] }, kind: FailureKind::Mismatch { expected: vec![] },
} }
} }
Node::Subgrammar(inner) => { Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
}
Node::ScopedSubgrammar(inner) => { Node::ScopedSubgrammar(inner) => {
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte) walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
} }
@@ -247,8 +237,7 @@ fn walk_node_inner(
// DynamicSubgrammar wrapper that delegates to the // DynamicSubgrammar wrapper that delegates to the
// memoized `column_value_list`), so the per-walk // memoized `column_value_list`), so the per-walk
// leak is a few bytes, not a whole typed tree. // leak is a few bytes, not a whole typed tree.
let resolved: &'static Node = let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
Box::leak(Box::new(factory(ctx, source, pos)));
walk_node(source, pos, resolved, ctx, path, per_byte) walk_node(source, pos, resolved, ctx, path, per_byte)
} }
Node::SetColumn(col) => { Node::SetColumn(col) => {
@@ -262,7 +251,10 @@ fn walk_node_inner(
let col: &crate::completion::TableColumn = col; let col: &crate::completion::TableColumn = col;
ctx.current_column = Some(col.clone()); ctx.current_column = Some(col.clone());
ctx.pending_value_column = Some(col.name.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 { Node::TypedValueSlot {
ty, ty,
@@ -342,7 +334,10 @@ fn walk_word(
// Amendment 4). Plain keywords leave it `None`. // Amendment 4). Plain keywords leave it `None`.
class: word.highlight_override.unwrap_or(HighlightClass::Keyword), class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
}); });
NodeWalkResult::Matched { end, skipped: Vec::new() } NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} else { } else {
NodeWalkResult::NoMatch { NodeWalkResult::NoMatch {
position, position,
@@ -477,9 +472,7 @@ fn walk_ident(
// ScopedSubgrammar (which is structurally guaranteed to be // ScopedSubgrammar (which is structurally guaranteed to be
// the CTE body — no intervening scoped subgrammar in CTE // the CTE body — no intervening scoped subgrammar in CTE
// syntax) runs the harvest at body-frame exit. // syntax) runs the harvest at body-frame exit.
if writes_cte_name if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
frame frame
.cte_bindings .cte_bindings
.push(crate::dsl::walker::context::CteBinding { .push(crate::dsl::walker::context::CteBinding {
@@ -487,13 +480,12 @@ fn walk_ident(
columns: Vec::new(), columns: Vec::new(),
}); });
let placeholder_index = frame.cte_bindings.len() - 1; let placeholder_index = frame.cte_bindings.len() - 1;
ctx.pending_cte_harvest = ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
Some(crate::dsl::walker::context::PendingCteHarvest { placeholder_index,
placeholder_index, col_list: Vec::new(),
col_list: Vec::new(), cte_name: text.clone(),
cte_name: text.clone(), cte_name_span: (start, end),
cte_name_span: (start, end), });
});
} }
// ADR-0032 §10.3: the optional `(c1, c2, …)` rename list // ADR-0032 §10.3: the optional `(c1, c2, …)` rename list
// between the cte name and `AS`. Each `cte_column` ident // 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 // ADR-0032 §10.4: projection-list alias accumulator for
// ORDER BY completion candidates. // ORDER BY completion candidates.
if writes_projection_alias if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
frame.projection_aliases.push(text.clone()); frame.projection_aliases.push(text.clone());
} }
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) { if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
@@ -529,9 +519,7 @@ fn walk_ident(
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.or_else(|| Some(text.clone())); .or_else(|| Some(text.clone()));
} }
if writes_user_listed_column if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
{
// Form A: `insert into <T> (col1, col2, …)`. Append the // Form A: `insert into <T> (col1, col2, …)`. Append the
// matched column name to user_listed_columns so the // matched column name to user_listed_columns so the
// inner `values (…)` slot list mirrors the user's // inner `values (…)` slot list mirrors the user's
@@ -564,7 +552,10 @@ fn walk_ident(
// (issue #8 / ADR-0022 Amendment 4). // (issue #8 / ADR-0022 Amendment 4).
class: highlight_override.unwrap_or(HighlightClass::Identifier), class: highlight_override.unwrap_or(HighlightClass::Identifier),
}); });
NodeWalkResult::Matched { end, skipped: Vec::new() } NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} }
fn walk_string_lit( fn walk_string_lit(
@@ -648,7 +639,10 @@ fn walk_literal(
end, end,
class, class,
}); });
NodeWalkResult::Matched { end, skipped: Vec::new() } NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} }
fn walk_number_lit( fn walk_number_lit(
@@ -683,7 +677,10 @@ fn walk_number_lit(
end, end,
class: HighlightClass::Number, class: HighlightClass::Number,
}); });
NodeWalkResult::Matched { end, skipped: Vec::new() } NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} }
fn walk_flag( fn walk_flag(
@@ -717,7 +714,10 @@ fn walk_flag(
end, end,
class: HighlightClass::Flag, class: HighlightClass::Flag,
}); });
NodeWalkResult::Matched { end, skipped: Vec::new() } NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -784,7 +784,10 @@ fn walk_repeated(
count += 1; count += 1;
last_item_skipped = skipped; last_item_skipped = skipped;
} }
NodeWalkResult::NoMatch { expected, position: inner_pos } => { NodeWalkResult::NoMatch {
expected,
position: inner_pos,
} => {
// Mid-typing-the-next-item recovery: if the // Mid-typing-the-next-item recovery: if the
// separator just consumed and the inner failed // separator just consumed and the inner failed
// at EOF, the user is partway through typing the // at EOF, the user is partway through typing the
@@ -860,7 +863,10 @@ fn walk_bare_path(
end, end,
class: HighlightClass::String, class: HighlightClass::String,
}); });
NodeWalkResult::Matched { end, skipped: Vec::new() } NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} }
fn walk_choice( fn walk_choice(
@@ -1031,7 +1037,10 @@ fn walk_optional(
skipped: expected, skipped: expected,
} }
} }
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => { NodeWalkResult::Incomplete {
position: p,
expected,
} if !inner_committed => {
// Inner reported Incomplete without consuming // Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's // anything — same as NoMatch from the user's
// perspective. Roll back and skip. // perspective. Roll back and skip.
@@ -1156,9 +1165,7 @@ fn walk_scoped_subgrammar(
// walks that NoMatch / Incomplete / Fail leave the placeholder // walks that NoMatch / Incomplete / Fail leave the placeholder
// empty (the outer-frame state is also discarded in the // empty (the outer-frame state is also discarded in the
// speculative path, so this is correct). // speculative path, so this is correct).
if let (Some(req), NodeWalkResult::Matched { end, .. }) = if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
(pending_cte, &result)
{
run_cte_harvest(ctx, path, source, pos, *end, &req); 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 select_idx = Some(i + 1); // start of projection list
} }
MatchedKind::Word( MatchedKind::Word(
"from" | "where" | "group" | "having" | "order" "from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
| "limit" | "offset" | "union" | "intersect" | "intersect" | "except",
| "except",
) if select_idx.is_some() => { ) if select_idx.is_some() => {
end_idx = i; end_idx = i;
break; break;
@@ -1281,12 +1287,7 @@ fn run_cte_harvest(
// Classify each projection item per ADR-0032 §10.3. // Classify each projection item per ADR-0032 §10.3.
let mut derived: Vec<CteColumn> = Vec::new(); let mut derived: Vec<CteColumn> = Vec::new();
for slice in item_slices { for slice in item_slices {
classify_projection_item( classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
slice,
body_frame,
&ctx.from_scope_stack,
&mut derived,
);
} }
// Apply (c1, c2, …) positional rename if provided. Types // Apply (c1, c2, …) positional rename if provided. Types
@@ -1339,8 +1340,7 @@ fn run_cte_harvest(
let stack_len = ctx.from_scope_stack.len(); let stack_len = ctx.from_scope_stack.len();
if stack_len >= 2 if stack_len >= 2
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2) && let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
&& let Some(placeholder) = && let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
outer.cte_bindings.get_mut(req.placeholder_index)
{ {
placeholder.columns = derived; placeholder.columns = derived;
} }
@@ -1368,9 +1368,7 @@ fn classify_projection_item(
// empty because it wasn't a base-table lookup), resolve // empty because it wasn't a base-table lookup), resolve
// through to the in-scope CteBinding so nested CTEs project // through to the in-scope CteBinding so nested CTEs project
// correctly. // correctly.
if expr_slice.len() == 1 if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
{
for binding in &body_frame.from_scope { for binding in &body_frame.from_scope {
for col in expand_binding(binding, scope_stack) { for col in expand_binding(binding, scope_stack) {
out.push(col); out.push(col);
@@ -1383,7 +1381,10 @@ fn classify_projection_item(
if expr_slice.len() == 3 if expr_slice.len() == 3
&& matches!( && matches!(
expr_slice[0].kind, 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[1].kind, MatchedKind::Punct('.'))
&& matches!(expr_slice[2].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 col_text = &expr_slice[0].text;
let resolved_type = resolve_bare_column_type_in_frame( let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
body_frame,
scope_stack,
col_text,
);
let name = alias.unwrap_or_else(|| col_text.clone()); let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn { out.push(CteColumn {
name: Some(name), name: Some(name),
@@ -1447,12 +1444,7 @@ fn classify_projection_item(
{ {
let qual = &expr_slice[0].text; let qual = &expr_slice[0].text;
let col_text = &expr_slice[2].text; let col_text = &expr_slice[2].text;
let resolved_type = resolve_qualified_column_type( let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
body_frame,
scope_stack,
qual,
col_text,
);
let name = alias.unwrap_or_else(|| col_text.clone()); let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn { out.push(CteColumn {
name: Some(name), name: Some(name),
@@ -1493,16 +1485,8 @@ fn strip_trailing_alias<'a>(
} }
) { ) {
// Optional preceding `AS` keyword. // Optional preceding `AS` keyword.
if slice.len() >= 2 if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
&& matches!( return (&slice[..slice.len() - 2], Some(last.text.clone()));
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())); 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
resolve_dynamic, walk_node, walk_node,
}; };
use crate::dsl::grammar::{Node, Word}; use crate::dsl::grammar::{Node, Word};
use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::context::WalkContext;
@@ -1629,18 +1613,14 @@ mod tests {
Node::Subgrammar(&NESTED), Node::Subgrammar(&NESTED),
Node::Punct(')'), Node::Punct(')'),
]; ];
static NESTED_CHOICES: &[Node] = &[ static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
Node::Seq(NESTED_GROUP),
Node::Word(Word::keyword("x")),
];
static NESTED: Node = Node::Choice(NESTED_CHOICES); static NESTED: Node = Node::Choice(NESTED_CHOICES);
fn walk_nested(input: &str) -> NodeWalkResult { fn walk_nested(input: &str) -> NodeWalkResult {
let mut ctx = WalkContext::new(); let mut ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::new(); let mut per_byte = Vec::new();
let result = let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!( assert_eq!(
ctx.subgrammar_depth, 0, ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk", "subgrammar_depth must be restored to 0 after the walk",
@@ -1726,14 +1706,8 @@ mod tests {
fn resolve_dynamic_cache_is_populated() { fn resolve_dynamic_cache_is_populated() {
let ctx = WalkContext::new(); let ctx = WalkContext::new();
let _ = resolve_dynamic(const_factory, &ctx); let _ = resolve_dynamic(const_factory, &ctx);
let populated = !DYNAMIC_CACHE let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
.lock() assert!(populated, "resolve_dynamic should populate the memo cache",);
.expect("cache lock")
.is_empty();
assert!(
populated,
"resolve_dynamic should populate the memo cache",
);
} }
// ---- ScopedSubgrammar (ADR-0032 §10.2) ----------------------- // ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
@@ -1758,14 +1732,7 @@ mod tests {
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::new(); let mut per_byte = Vec::new();
let baseline_frames = ctx.from_scope_stack.len(); let baseline_frames = ctx.from_scope_stack.len();
let result = walk_node( let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
input,
0,
&SCOPED_NESTED,
&mut ctx,
&mut path,
&mut per_byte,
);
assert_eq!( assert_eq!(
ctx.subgrammar_depth, 0, ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk", "subgrammar_depth must be restored to 0 after the walk",
@@ -1801,9 +1768,9 @@ mod tests {
kind: FailureKind::Validation(err), kind: FailureKind::Validation(err),
.. ..
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"), } => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
other => panic!( other => {
"expected expression_too_deep on pathological scoped nesting, got {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 /// Walk a top-level SQL SELECT and return the bottom frame's
/// `from_scope` after the walk completes. Used to verify that /// `from_scope` after the walk completes. Used to verify that
/// `writes_table` / `writes_table_alias` populate bindings. /// `writes_table` / `writes_table_alias` populate bindings.
fn from_scope_after_walk( fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
input: &str,
) -> Vec<crate::dsl::walker::context::TableBinding> {
let mut ctx = WalkContext::new(); let mut ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::new(); let mut per_byte = Vec::new();
@@ -1871,9 +1836,7 @@ mod tests {
#[test] #[test]
fn join_pushes_a_second_binding() { fn join_pushes_a_second_binding() {
let bindings = from_scope_after_walk( let bindings = from_scope_after_walk("select * from a join b on x = y");
"select * from a join b on x = y",
);
assert_eq!(bindings.len(), 2); assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a"); assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b"); assert_eq!(bindings[1].table, "b");
@@ -1881,9 +1844,7 @@ mod tests {
#[test] #[test]
fn join_with_aliases() { fn join_with_aliases() {
let bindings = from_scope_after_walk( let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
"select * from a as x join b as y on x.id = y.id",
);
assert_eq!(bindings.len(), 2); assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a"); assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[0].alias, Some("x".to_string())); assert_eq!(bindings[0].alias, Some("x".to_string()));
@@ -1893,9 +1854,8 @@ mod tests {
#[test] #[test]
fn three_way_join_pushes_three_bindings() { fn three_way_join_pushes_three_bindings() {
let bindings = from_scope_after_walk( let bindings =
"select * from a join b on x = y left join c on y = z", 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.len(), 3);
assert_eq!(bindings[0].table, "a"); assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b"); assert_eq!(bindings[1].table, "b");
@@ -1908,9 +1868,8 @@ mod tests {
// binding into the inner scope frame; on exit, the frame // binding into the inner scope frame; on exit, the frame
// pops and the inner binding is gone. The outer scope's // pops and the inner binding is gone. The outer scope's
// from_scope still contains only `outer_t`. // from_scope still contains only `outer_t`.
let bindings = from_scope_after_walk( let bindings =
"select * from outer_t where id in (select id from inner_t)", from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
);
assert_eq!(bindings.len(), 1); assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "outer_t"); assert_eq!(bindings[0].table, "outer_t");
} }
@@ -1921,9 +1880,8 @@ mod tests {
// body's scope frame; on body-frame exit, the inner // body's scope frame; on body-frame exit, the inner
// binding goes away. The outer scope contains only // binding goes away. The outer scope contains only
// the CTE-name reference `cte_x`. // the CTE-name reference `cte_x`.
let bindings = from_scope_after_walk( let bindings =
"with cte_x as (select * from base_table) select * from cte_x", from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
);
assert_eq!(bindings.len(), 1); assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "cte_x"); assert_eq!(bindings[0].table, "cte_x");
} }
@@ -1940,10 +1898,7 @@ mod tests {
/// `cte_bindings` and `projection_aliases` after the walk. /// `cte_bindings` and `projection_aliases` after the walk.
fn frame_state_after_walk( fn frame_state_after_walk(
input: &str, 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 ctx = WalkContext::new();
let mut path = MatchedPath::new(); let mut path = MatchedPath::new();
let mut per_byte = Vec::new(); let mut per_byte = Vec::new();
@@ -1968,9 +1923,7 @@ mod tests {
#[test] #[test]
fn cte_name_pushes_placeholder_binding() { fn cte_name_pushes_placeholder_binding() {
let (ctes, _) = frame_state_after_walk( let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
"with cte_x as (select 1) select * from cte_x",
);
assert_eq!(ctes.len(), 1); assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].name, "cte_x"); assert_eq!(ctes[0].name, "cte_x");
// §10.3 stage-2 harvest produces one CteColumn per // §10.3 stage-2 harvest produces one CteColumn per
@@ -1984,9 +1937,8 @@ mod tests {
#[test] #[test]
fn multiple_ctes_push_in_order() { fn multiple_ctes_push_in_order() {
let (ctes, _) = frame_state_after_walk( let (ctes, _) =
"with a as (select 1), b as (select 2) select * from b", frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
);
assert_eq!(ctes.len(), 2); assert_eq!(ctes.len(), 2);
assert_eq!(ctes[0].name, "a"); assert_eq!(ctes[0].name, "a");
assert_eq!(ctes[1].name, "b"); assert_eq!(ctes[1].name, "b");
@@ -2006,25 +1958,20 @@ mod tests {
#[test] #[test]
fn projection_aliases_captured_via_as_form() { fn projection_aliases_captured_via_as_form() {
let (_, aliases) = frame_state_after_walk( let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
"select a as alpha, b as beta from t",
);
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]); assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
} }
#[test] #[test]
fn projection_aliases_captured_via_bare_form() { fn projection_aliases_captured_via_bare_form() {
let (_, aliases) = frame_state_after_walk( let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
"select a alpha, b beta from t",
);
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]); assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
} }
#[test] #[test]
fn projection_aliases_mixed_forms() { fn projection_aliases_mixed_forms() {
let (_, aliases) = frame_state_after_walk( let (_, aliases) =
"select a as alpha, b beta, c, d as delta from t", frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
);
assert_eq!( assert_eq!(
aliases, aliases,
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()] vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
@@ -2033,8 +1980,7 @@ mod tests {
#[test] #[test]
fn projection_aliases_empty_when_no_aliases() { fn projection_aliases_empty_when_no_aliases() {
let (_, aliases) = let (_, aliases) = frame_state_after_walk("select a, b from t");
frame_state_after_walk("select a, b from t");
assert!(aliases.is_empty()); assert!(aliases.is_empty());
} }
@@ -2088,9 +2034,24 @@ mod tests {
s.table_columns.insert( s.table_columns.insert(
"users".to_string(), "users".to_string(),
vec![ vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, TableColumn {
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false }, name: "id".to_string(),
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false }, 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 s
@@ -2108,10 +2069,7 @@ mod tests {
assert_eq!(ctes.len(), 1); assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].columns.len(), 3); assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id")); assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
assert_eq!( assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
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].name.as_deref(), Some("name"));
assert_eq!( assert_eq!(
ctes[0].columns[1].type_, ctes[0].columns[1].type_,
@@ -2161,10 +2119,7 @@ mod tests {
); );
assert_eq!(ctes[0].columns.len(), 1); assert_eq!(ctes[0].columns.len(), 1);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age")); assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
assert_eq!( assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
} }
#[test] #[test]
@@ -2259,15 +2214,9 @@ mod tests {
.expect("outer_cte binding"); .expect("outer_cte binding");
assert_eq!(outer.columns.len(), 2); assert_eq!(outer.columns.len(), 2);
assert_eq!(outer.columns[0].name.as_deref(), Some("id")); assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
assert_eq!( assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
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].name.as_deref(), Some("name"));
assert_eq!( assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
outer.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
} }
#[test] #[test]
@@ -2287,15 +2236,9 @@ mod tests {
let b = ctes.iter().find(|c| c.name == "b").expect("b binding"); let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
assert_eq!(b.columns.len(), 2); assert_eq!(b.columns.len(), 2);
assert_eq!(b.columns[0].name.as_deref(), Some("id")); assert_eq!(b.columns[0].name.as_deref(), Some("id"));
assert_eq!( assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
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].name.as_deref(), Some("name"));
assert_eq!( assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
b.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
} }
#[test] #[test]
@@ -2310,10 +2253,7 @@ mod tests {
); );
assert_eq!(ctes[0].columns.len(), 3); assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a")); assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
assert_eq!( assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
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].name.as_deref(), Some("b"));
assert_eq!( assert_eq!(
ctes[0].columns[1].type_, ctes[0].columns[1].type_,
+41 -28
View File
@@ -24,8 +24,8 @@
use crate::dsl::grammar::HighlightClass; use crate::dsl::grammar::HighlightClass;
use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{ use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
consume_string_literal, skip_whitespace, skip_whitespace,
}; };
use crate::dsl::walker::outcome::{ByteClass, WalkBound}; 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 /// token, producing the keyword classes the renderer needs to
/// colour `select` / `from` / `where` / `union` / `case` / etc. /// colour `select` / `from` / `where` / `union` / `case` / etc.
#[must_use] #[must_use]
pub fn highlight_runs_in_mode( pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
source: &str,
mode: crate::mode::Mode,
) -> Vec<ByteClass> {
let mut ctx = WalkContext::new(); let mut ctx = WalkContext::new();
ctx.mode = mode; ctx.mode = mode;
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx); let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
let mut classes: Vec<ByteClass> = result let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
.map(|r| r.per_byte_class)
.unwrap_or_default();
let scan_start = classes.last().map_or(0, |c| c.end); let scan_start = classes.last().map_or(0, |c| c.end);
scan_remainder(source, scan_start, &mut classes); 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) .get(pos + 1)
.copied() .copied()
.is_some_and(|c| c.is_ascii_digit())); .is_some_and(|c| c.is_ascii_digit()));
if looks_like_number if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
&& let Some((s, e)) = consume_number_literal(source, pos)
{
classes.push(ByteClass { classes.push(ByteClass {
start: s, start: s,
end: e, end: e,
@@ -222,8 +215,14 @@ mod tests {
"no Error highlight on a valid m:n line: {runs:?}" "no Error highlight on a valid m:n line: {runs:?}"
); );
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect(); let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}"); assert!(
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}"); kinds.contains(&HighlightClass::Keyword),
"keywords highlighted: {runs:?}"
);
assert!(
kinds.contains(&HighlightClass::Identifier),
"table names highlighted: {runs:?}"
);
} }
#[test] #[test]
@@ -276,10 +275,7 @@ mod tests {
#[test] #[test]
fn flag_classified_via_fallback() { fn flag_classified_via_fallback() {
// Walker doesn't engage for a bare `--all-rows`. // Walker doesn't engage for a bare `--all-rows`.
assert_eq!( assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
run("--all-rows"),
vec![(0, 10, HighlightClass::Flag)],
);
} }
#[test] #[test]
@@ -445,15 +441,13 @@ mod tests {
// dispatcher, so only the entry word would highlight). // dispatcher, so only the entry word would highlight).
let runs = run_advanced("select * from t"); let runs = run_advanced("select * from t");
assert!( assert!(
runs.iter().any(|(s, e, c)| { runs.iter()
*c == HighlightClass::Keyword && (*s, *e) == (0, 6) .any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
}),
"expected `select` keyword span 0..6; got {runs:?}", "expected `select` keyword span 0..6; got {runs:?}",
); );
assert!( assert!(
runs.iter().any(|(s, e, c)| { runs.iter()
*c == HighlightClass::Keyword && (*s, *e) == (9, 13) .any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
}),
"expected `from` keyword span 9..13; got {runs:?}", "expected `from` keyword span 9..13; got {runs:?}",
); );
} }
@@ -514,18 +508,37 @@ mod tests {
let insert = keywords_of( let insert = keywords_of(
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a", "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"] { for kw in [
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}"); "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"); let update = keywords_of("update t set a = 1 where id = 2 returning a");
for kw in ["update", "set", "where", "returning"] { 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 *"); let delete = keywords_of("delete from t where id = 1 returning *");
for kw in ["delete", "from", "where", "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; return None;
} }
let mut i = start; let mut i = start;
let leading_minus = bytes[i] == b'-' let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit();
if leading_minus { if leading_minus {
i += 1; 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). //! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode; use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command; use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{ use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter, ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
}; };
use crate::dsl::types::Type;
use crate::dsl::value::Value; use crate::dsl::value::Value;
/// The dimmed `Executing SQL:` prefix on a teaching-echo line /// The dimmed `Executing SQL:` prefix on a teaching-echo line
@@ -79,7 +79,12 @@ pub fn echo_for_query(
name, name,
filter, filter,
limit, 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, _ => None,
} }
} }
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
column, column,
kind, kind,
} => match kind { } => match kind {
ConstraintKind::NotNull => { ConstraintKind::NotNull => Some(format!(
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL")) "ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
} )),
ConstraintKind::Default => { ConstraintKind::Default => Some(format!(
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT")) "ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
} )),
// A column-level UNIQUE / CHECK is anonymous in our model — // A column-level UNIQUE / CHECK is anonymous in our model —
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C, // no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7). // ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
table, table,
assignments, assignments,
filter: RowFilter::AllRows, filter: RowFilter::AllRows,
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))), } => Some(format!(
"UPDATE {table} SET {}",
render_assignments(assignments)
)),
Command::Delete { Command::Delete {
table, table,
filter: RowFilter::AllRows, 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): // The same column-constraint suffix `add column` emits (ADR-0029):
// simple-mode `create table` can carry `default` / `check` too, so // simple-mode `create table` can carry `default` / `check` too, so
// the echo must render them or it is not equivalent (§1 contract). // 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 s
}) })
.collect(); .collect();
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
primary_key: &[String], primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)], foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String { ) -> String {
let mut parts: Vec<String> = let mut parts: Vec<String> = columns
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect(); .iter()
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
.collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", "))); parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys { for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!( parts.push(format!(
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
) -> Vec<String> { ) -> Vec<String> {
let mut lines: Vec<String> = new_columns let mut lines: Vec<String> = new_columns
.iter() .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(); .collect();
lines.push(render_add_relationship( lines.push(render_add_relationship(
name, name,
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
negated, negated,
} => { } => {
let not = if *negated { "NOT " } else { "" }; 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 { Predicate::Between {
target, target,
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
} => { } => {
let not = if *negated { "NOT " } else { "" }; let not = if *negated { "NOT " } else { "" };
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect(); 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 } => { Predicate::IsNull { target, negated } => {
let not = if *negated { "NOT " } else { "" }; let not = if *negated { "NOT " } else { "" };
@@ -562,7 +591,10 @@ mod tests {
fn create_table_compound_pk_renders_table_level() { fn create_table_compound_pk_renders_table_level() {
let cmd = create_table( let cmd = create_table(
"T", "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"], &["a", "b"],
); );
assert_eq!( assert_eq!(
@@ -594,7 +626,11 @@ mod tests {
default: Some(Value::Text("A".to_string())), default: Some(Value::Text("A".to_string())),
..ColumnSpec::new("grade", Type::Text) ..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"); let sql = command_to_sql(&cmd).expect("echo");
assert_eq!( assert_eq!(
sql, sql,
@@ -625,11 +661,11 @@ mod tests {
check: None, check: None,
}; };
let sql = command_to_sql(&cmd).expect("echo"); let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"); assert_eq!(
assert!(matches!( sql,
reparse(&sql), "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
Ok(Command::SqlAlterTable { .. }) );
)); assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
} }
#[test] #[test]
@@ -657,7 +693,10 @@ mod tests {
})), })),
}; };
let sql = command_to_sql(&cmd).expect("echo"); 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 { .. }))); assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
} }
@@ -1031,7 +1070,10 @@ mod tests {
let lines = render_drop_column_cascade( let lines = render_drop_column_cascade(
"Orders", "Orders",
"CustId", "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!( assert_eq!(
lines.as_slice(), lines.as_slice(),
@@ -1043,9 +1085,18 @@ mod tests {
); );
// Each line is itself runnable advanced-mode SQL (the §1 contract // Each line is itself runnable advanced-mode SQL (the §1 contract
// holds per line for category 2). // holds per line for category 2).
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. }))); assert!(matches!(
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. }))); reparse(&lines[0]),
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. }))); Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[2]),
Ok(Command::SqlAlterTable { .. })
));
} }
#[test] #[test]
@@ -1054,7 +1105,10 @@ mod tests {
// plain `DROP COLUMN` — still semantically equivalent. // plain `DROP COLUMN` — still semantically equivalent.
let lines = render_drop_column_cascade("T", "c", &[]); let lines = render_drop_column_cascade("T", "c", &[]);
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN 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] #[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", "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!(
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. }))); reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlAlterTable { .. })
));
} }
#[test] #[test]
@@ -1116,8 +1176,16 @@ mod tests {
], ],
&["Students_id".to_string(), "Courses_id".to_string()], &["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!( assert_eq!(
@@ -1172,8 +1240,14 @@ mod tests {
#[test] #[test]
fn value_literal_renders_null_uppercase_and_quotes_text() { 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::Null), "NULL");
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'"); assert_eq!(
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14"); 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"); assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
} }
@@ -1258,9 +1332,7 @@ mod tests {
"Command::App({app:?}) is Bucket C — no echo" "Command::App({app:?}) is Bucket C — no echo"
); );
// Also confirm echo_for gates the same in advanced mode. // Also confirm echo_for gates the same in advanced mode.
assert!( assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
);
} }
} }
+10 -5
View File
@@ -8,9 +8,8 @@
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crate::db::{ use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
UpdateResult,
}; };
use crate::dsl::Command; use crate::dsl::Command;
@@ -73,10 +72,16 @@ pub enum AppEvent {
}, },
/// An `explain …` command succeeded (ADR-0028). `plan` /// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed. /// 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 /// A `show <kind>` list command (V5) — carries pre-formatted
/// display lines (tables / relationships / indexes). /// 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 /// `show relationship <name>` (ADR-0044) — structured data for the
/// diagram, rendered App-side; `None` when no such relationship. /// diagram, rendered App-side; `None` when no such relationship.
DslShowRelationshipSucceeded { DslShowRelationshipSucceeded {
+3 -11
View File
@@ -43,17 +43,11 @@ impl Catalog {
} }
} }
fn flatten( fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
value: &serde_norway::Value,
prefix: String,
out: &mut HashMap<String, String>,
) {
match value { match value {
serde_norway::Value::Mapping(map) => { serde_norway::Value::Mapping(map) => {
for (k, v) in map { for (k, v) in map {
let k_str = k let k_str = k.as_str().expect("catalog keys must be strings");
.as_str()
.expect("catalog keys must be strings");
let next = if prefix.is_empty() { let next = if prefix.is_empty() {
k_str.to_string() k_str.to_string()
} else { } else {
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
/// See module docs for failure modes. /// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String { pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
let template = catalog().get(key).unwrap_or_else(|| { let template = catalog().get(key).unwrap_or_else(|| {
panic!( panic!("missing catalog key: `{key}` (the validator should have caught this)");
"missing catalog key: `{key}` (the validator should have caught this)"
);
}); });
substitute(template, args, key) substitute(template, args, key)
} }
+24 -29
View File
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("diagnostic.alias_used_as_column", &["name"]), ("diagnostic.alias_used_as_column", &["name"]),
("diagnostic.ambiguous_column", &["column", "qualifiers"]), ("diagnostic.ambiguous_column", &["column", "qualifiers"]),
("diagnostic.auto_column_overridden", &["column", "type"]), ("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.duplicate_cte", &["name"]),
("diagnostic.eq_null", &[]), ("diagnostic.eq_null", &[]),
("diagnostic.insert_arity_mismatch", &["expected", "actual"]), ("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
), ),
("diagnostic.not_null_missing", &["column"]), ("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]), ("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]), (
"diagnostic.projection_alias_misplaced",
&["alias", "clause"],
),
("diagnostic.table_used_as_column", &["name"]), ("diagnostic.table_used_as_column", &["name"]),
("diagnostic.type_mismatch", &["column", "type"]), ("diagnostic.type_mismatch", &["column", "type"]),
("diagnostic.unknown_column", &["name", "table"]), ("diagnostic.unknown_column", &["name", "table"]),
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"error.type_mismatch.change_column.headline", "error.type_mismatch.change_column.headline",
&["table", "column", "src_type", "target_type"], &["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", "error.type_mismatch.insert.headline",
&["value", "expected_type"], &["value", "expected_type"],
@@ -219,10 +225,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.explain", &[]), ("help.data.explain", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ---- // ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]), ("hint.ambient_complete", &[]),
( ("hint.ambient_error_with_usage", &["message", "usage"]),
"hint.ambient_error_with_usage",
&["message", "usage"],
),
("hint.ambient_expected", &["expected"]), ("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]), ("hint.getting_started", &[]),
("hint.block.heading", &[]), ("hint.block.heading", &[]),
@@ -404,10 +407,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.cmd.explain_sql.what", &[]), ("hint.cmd.explain_sql.what", &[]),
("hint.cmd.explain_sql.example", &[]), ("hint.cmd.explain_sql.example", &[]),
("hint.cmd.explain_sql.concept", &[]), ("hint.cmd.explain_sql.concept", &[]),
( ("hint.ambient_invalid_ident", &["kind", "found"]),
"hint.ambient_invalid_ident",
&["kind", "found"],
),
("hint.ambient_typing_name", &[]), ("hint.ambient_typing_name", &[]),
// Issue #4: introduce the advanced-mode CREATE TABLE element // Issue #4: introduce the advanced-mode CREATE TABLE element
// slot (`create table T (`) so the otherwise-invisible // slot (`create table T (`) so the otherwise-invisible
@@ -415,10 +415,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.create_table_element", &[]), ("hint.create_table_element", &[]),
("hint.seed_count", &[]), ("hint.seed_count", &[]),
("hint.value_literal_slot", &[]), ("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). // Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]), ("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]), ("hint.value_slot_bool", &[]),
@@ -441,7 +438,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.custom.alter_named_unique", &[]), ("parse.custom.alter_named_unique", &[]),
("parse.custom.bind_type_mismatch", &["found", "expected"]), ("parse.custom.bind_type_mismatch", &["found", "expected"]),
("parse.custom.change_column_flags_exclusive", &[]), ("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.create_table_needs_pk", &[]),
("parse.custom.expression_too_deep", &[]), ("parse.custom.expression_too_deep", &[]),
("parse.custom.insert_form_a_missing_values", &["columns"]), ("parse.custom.insert_form_a_missing_values", &["columns"]),
@@ -576,10 +576,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["table", "col_count", "col_list", "supplied", "non_auto_csv"], &["table", "col_count", "col_list", "supplied", "non_auto_csv"],
), ),
("select.internal_table", &["table"]), ("select.internal_table", &["table"]),
( ("cli.invalid_value", &["flag", "value", "expected"]),
"cli.invalid_value",
&["flag", "value", "expected"],
),
("cli.missing_value", &["flag"]), ("cli.missing_value", &["flag"]),
("cli.multiple_paths", &["first", "second"]), ("cli.multiple_paths", &["first", "second"]),
("cli.resume_with_path", &[]), ("cli.resume_with_path", &[]),
@@ -867,8 +864,7 @@ mod tests {
} }
} }
let declared: HashSet<&str> = let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
for key in cat.keys() { for key in cat.keys() {
if key.starts_with("_test.") { if key.starts_with("_test.") {
continue; continue;
@@ -890,9 +886,8 @@ mod tests {
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`, /// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
/// duplicated here so the catalog validator is self-contained /// duplicated here so the catalog validator is self-contained
/// (no dependency on the integration-test binary). /// (no dependency on the integration-test binary).
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[ const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA", &["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
];
/// Detect a `{name:...}` format-specifier placeholder. /// Detect a `{name:...}` format-specifier placeholder.
/// Doubled braces `{{` / `}}` are escapes — must skip them. /// Doubled braces `{{` / `}}` are escapes — must skip them.
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod keys;
pub mod translate; pub mod translate;
pub use error::{DiagnosticTable, FriendlyError}; pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog}; pub use format::{Catalog, catalog};
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity}; pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
// `translate::translate` and `format::translate` are different // `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError // callables — the former is the structured DbError → FriendlyError
+79 -76
View File
@@ -201,11 +201,7 @@ impl TranslateContext {
/// Combine schema-resolved facts with operation and /// Combine schema-resolved facts with operation and
/// verbosity to build the full translator input. /// verbosity to build the full translator input.
#[must_use] #[must_use]
pub fn from_facts( pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self {
operation: Operation,
verbosity: Verbosity,
facts: FailureContext,
) -> Self {
Self { Self {
operation: Some(operation), operation: Some(operation),
table: facts.table, table: facts.table,
@@ -234,15 +230,15 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
// refusal sites). Catalog entries exist for the typed // refusal sites). Catalog entries exist for the typed
// invalid-value cases but the migration sweep // invalid-value cases but the migration sweep
// (ADR-0019 §9) is what wires them. For now, passthrough. // (ADR-0019 §9) is what wires them. For now, passthrough.
DbError::Unsupported(message) | DbError::InvalidValue(message) => { DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message),
passthrough(message)
}
DbError::PersistenceFatal { message, .. } DbError::PersistenceFatal { message, .. }
| DbError::RebuildRowFailed { detail: message, .. } | DbError::RebuildRowFailed {
detail: message, ..
}
| DbError::Io(message) => passthrough(message), | DbError::Io(message) => passthrough(message),
DbError::WorkerGone => passthrough( DbError::WorkerGone => {
"the database worker is no longer available — the application must restart", passthrough("the database worker is no longer available — the application must restart")
), }
}; };
// Attach the row pinpoint when the runtime resolved one. // Attach the row pinpoint when the runtime resolved one.
// The translator never builds the table itself — it only // 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( fn translate_sqlite(message: &str, kind: SqliteErrorKind, ctx: &TranslateContext) -> FriendlyError {
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> FriendlyError {
// `change column ... --dont-convert` lets the engine // `change column ... --dont-convert` lets the engine
// accept or refuse each cell. Whatever the engine returns // accept or refuse each cell. Whatever the engine returns
// (constraint, datatype mismatch, …) means "the new type // (constraint, datatype mismatch, …) means "the new type
@@ -392,8 +384,8 @@ fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError
// ---- UNIQUE ----------------------------------------------------- // ---- UNIQUE -----------------------------------------------------
fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError { fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message) let (table, column) =
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx))); parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let value = ctx_value(ctx); let value = ctx_value(ctx);
match ctx.operation { match ctx.operation {
Some(Operation::Update) => fe( Some(Operation::Update) => fe(
@@ -405,11 +397,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
), ),
verbose_hint( verbose_hint(
ctx, ctx,
t!( t!("error.unique.update.hint", table = table, column = column),
"error.unique.update.hint",
table = table,
column = column
),
), ),
), ),
// Default to the INSERT variant — it's the most common // Default to the INSERT variant — it's the most common
@@ -425,11 +413,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
), ),
verbose_hint( verbose_hint(
ctx, ctx,
t!( t!("error.unique.insert.hint", table = table, column = column),
"error.unique.insert.hint",
table = table,
column = column
),
), ),
), ),
} }
@@ -542,8 +526,8 @@ fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError {
// ---- NOT NULL -------------------------------------------------- // ---- NOT NULL --------------------------------------------------
fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError { fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message) let (table, column) =
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx))); parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
match ctx.operation { match ctx.operation {
Some(Operation::Update) => fe( Some(Operation::Update) => fe(
t!( t!(
@@ -576,9 +560,17 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
let column = ctx_column(ctx); let column = ctx_column(ctx);
let is_update = matches!(ctx.operation, Some(Operation::Update)); let is_update = matches!(ctx.operation, Some(Operation::Update));
let headline = if is_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 { } 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( 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 -------------------------------- // ---- not_found / already_exists --------------------------------
fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError { fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError {
let name = parse_after_colon(message) let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string);
.map_or_else(|| ctx_table(ctx), str::to_string);
headline_only(t!("error.not_found.table.headline", name = name)) 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 column = column
)); ));
} }
return headline_only(t!( return headline_only(t!("error.already_exists.table.headline", name = name));
"error.already_exists.table.headline",
name = name
));
} }
// No backticks — engine-style "table T already exists". // No backticks — engine-style "table T already exists".
if let Some(name) = parse_after_word(message, "table") { if let Some(name) = parse_after_word(message, "table") {
return headline_only(t!( return headline_only(t!("error.already_exists.table.headline", name = name));
"error.already_exists.table.headline",
name = name
));
} }
if let Some(name) = parse_after_word(message, "relationship") { if let Some(name) = parse_after_word(message, "relationship") {
return headline_only(t!( return headline_only(t!(
@@ -696,36 +681,25 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
if lower.contains("misuse of aggregate") { if lower.contains("misuse of aggregate") {
return headline_only(t!("engine.aggregate_misuse", name = "?")); return headline_only(t!("engine.aggregate_misuse", name = "?"));
} }
if lower.contains("group by") if lower.contains("group by") || lower.contains("must appear in") {
|| lower.contains("must appear in")
{
return headline_only(t!("engine.group_by_required")); return headline_only(t!("engine.group_by_required"));
} }
if (lower.contains("union") if (lower.contains("union") || lower.contains("intersect") || lower.contains("except"))
|| lower.contains("intersect")
|| lower.contains("except"))
&& lower.contains("result columns") && lower.contains("result columns")
{ {
// Last-resort safety net — the pre-flight pass in 2d.1 // Last-resort safety net — the pre-flight pass in 2d.1
// catches this in most cases; if the engine surfaces it // catches this in most cases; if the engine surfaces it
// anyway, route it through the engine-neutral key. // anyway, route it through the engine-neutral key.
return headline_only(t!( return headline_only(t!("engine.compound_arity_mismatch", op = "set operator"));
"engine.compound_arity_mismatch",
op = "set operator"
));
} }
if lower.contains("scalar subquery") || lower.contains("more than one row") { if lower.contains("scalar subquery") || lower.contains("more than one row") {
return headline_only(t!("engine.scalar_subquery_too_many_rows")); return headline_only(t!("engine.scalar_subquery_too_many_rows"));
} }
if lower.contains("recursive") if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) {
&& (lower.contains("cte") || lower.contains("union"))
{
return headline_only(t!("engine.recursive_cte_malformed")); return headline_only(t!("engine.recursive_cte_malformed"));
} }
let operation = ctx let operation = ctx.operation.map_or("operation", Operation::keyword);
.operation
.map_or("operation", Operation::keyword);
// F2 (ADR-0035 Amendment 1): when no table is in context, use the // F2 (ADR-0035 Amendment 1): when no table is in context, use the
// table-less hint so a contextless `friendly_message()` (replay, undo, // table-less hint so a contextless `friendly_message()` (replay, undo,
// rebuild, export) never renders a literal `{table}` placeholder. // rebuild, export) never renders a literal `{table}` placeholder.
@@ -789,23 +763,33 @@ fn ctx_table(ctx: &TranslateContext) -> String {
} }
fn ctx_column(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 { 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 { 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 { 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 { 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 /// 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 rest = message[pos..].trim_start();
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); 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 == '\''); let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
if token.is_empty() { if token.is_empty() { None } else { Some(token) }
None
} else {
Some(token)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -876,15 +856,24 @@ mod tests {
}; };
let d = TranslateContext::default; let d = TranslateContext::default;
assert_eq!( 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") Some("not_found")
); );
assert_eq!( 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") Some("not_found")
); );
assert_eq!( assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()), error_hint_class(
&sqlite(SqliteErrorKind::AlreadyExists, "already exists"),
&d()
),
Some("already_exists") Some("already_exists")
); );
assert_eq!( assert_eq!(
@@ -933,13 +922,19 @@ mod tests {
parent_table: Some("Parent".to_string()), parent_table: Some("Parent".to_string()),
..TranslateContext::default() ..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. // child_table populated → parent-side.
let ctx = TranslateContext { let ctx = TranslateContext {
child_table: Some("Child".to_string()), child_table: Some("Child".to_string()),
..TranslateContext::default() ..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. // No enrichment: operation is the tiebreaker.
assert_eq!( assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Delete)), error_hint_class(&fk(), &ctx_with(Operation::Delete)),
@@ -1049,14 +1044,22 @@ mod tests {
ctx.parent_column = Some("country, code".to_string()); ctx.parent_column = Some("country, code".to_string());
ctx.value = Some("7, 8".to_string()); ctx.value = Some("7, 8".to_string());
let f = translate(&err, &ctx); 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("Region"));
assert!( assert!(
f.headline.contains("country, code"), f.headline.contains("country, code"),
"both parent columns must appear: {}", "both parent columns must appear: {}",
f.headline f.headline
); );
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline); assert!(
f.headline.contains("`7, 8`"),
"joined value: {}",
f.headline
);
} }
#[test] #[test]
+139 -143
View File
@@ -25,9 +25,9 @@
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode}; 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::walker;
use crate::dsl::{ParseError, parse_command}; use crate::dsl::{ParseError, parse_command};
use crate::mode::Mode;
use crate::theme::Theme; use crate::theme::Theme;
/// A run of text with its byte range in the source and the /// 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, mode: Mode,
) -> Vec<StyledRun> { ) -> Vec<StyledRun> {
// Identity feedback view — highlight/overlay the whole input. // 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 /// [`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), byte_range: (0, offset),
style: ratatui::style::Style::default().fg(theme.fg), style: ratatui::style::Style::default().fg(theme.fg),
}]; }];
r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| { r.extend(
StyledRun { lex_to_runs_in_mode(view, theme, mode)
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset), .into_iter()
..run .map(|run| StyledRun {
} byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
})); ..run
}),
);
r r
}; };
if let InputState::DefiniteErrorAt(pos) = if let InputState::DefiniteErrorAt(pos) =
@@ -150,7 +161,11 @@ pub fn render_input_runs_feedback(
walker::Severity::Error => theme.tok_error, walker::Severity::Error => theme.tok_error,
walker::Severity::Warning => theme.warning, 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); inject_cursor(&mut runs, input, cursor_byte, theme);
runs runs
@@ -234,9 +249,7 @@ pub fn classify_input_with_schema_in_mode(
)) ))
} }
fn classify_parse_result( fn classify_parse_result(result: Result<crate::dsl::Command, ParseError>) -> InputState {
result: Result<crate::dsl::Command, ParseError>,
) -> InputState {
match result { match result {
Ok(_) => InputState::Valid, Ok(_) => InputState::Valid,
Err(ParseError::Empty) => InputState::Empty, Err(ParseError::Empty) => InputState::Empty,
@@ -372,8 +385,7 @@ pub fn advanced_alternative_note(
// carries a blocking ERROR diagnostic such as a value-count // carries a blocking ERROR diagnostic such as a value-count
// mismatch. Incomplete input (still being typed) and empty input are // mismatch. Incomplete input (still being typed) and empty input are
// excluded so the pointer doesn't flicker mid-keystroke. // 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::DefiniteErrorAt(_) => true,
InputState::Valid => { InputState::Valid => {
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple) 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 // The validity-verdict-driven gate (ADR-0033 Amendment 5): the
// line must be fully valid (verdict `None`) in advanced mode. // 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; return None;
} }
Some(crate::t!("advanced_mode.also_valid_sql")) 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 // narrows column candidates to the active table and runs the
// §10.6 look-ahead, so it is the authoritative "what can go // §10.6 look-ahead, so it is the authoritative "what can go
// here" set. // here" set.
let completion = let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
// Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics` // Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
// is non-empty only for a command that *structurally parses* // is non-empty only for a command that *structurally parses*
@@ -834,7 +844,9 @@ fn ambient_hint_core_in_mode(
// keyword set. // keyword set.
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[]))); 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 => {} | None => {}
} }
@@ -855,7 +867,8 @@ fn ambient_hint_core_in_mode(
// Invalid identifier: cursor sits in a known-set slot but // Invalid identifier: cursor sits in a known-set slot but
// the typed prefix matches nothing in the schema. (Stage // the typed prefix matches nothing in the schema. (Stage
// 8e / the user's #5.) // 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 { let kind = match inv.source {
crate::dsl::grammar::IdentSource::Tables => "table", 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 /// with `Mode::Advanced` so SQL keywords past the entry word
/// match and get highlighted (ADR-0030 §8). /// match and get highlighted (ADR-0030 §8).
#[must_use] #[must_use]
pub fn lex_to_runs_in_mode( pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
input: &str,
theme: &Theme,
mode: Mode,
) -> Vec<StyledRun> {
base_runs(input, theme, mode) base_runs(input, theme, mode)
} }
@@ -1076,12 +1085,7 @@ fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
runs runs
} }
fn inject_cursor( fn inject_cursor(runs: &mut Vec<StyledRun>, input: &str, cursor_byte: usize, theme: &Theme) {
runs: &mut Vec<StyledRun>,
input: &str,
cursor_byte: usize,
theme: &Theme,
) {
let cursor_byte = cursor_byte.min(input.len()); let cursor_byte = cursor_byte.min(input.len());
// End-of-input cursor: append the empty-range sentinel. // End-of-input cursor: append the empty-range sentinel.
@@ -1164,9 +1168,10 @@ mod tests {
let mut cache = SchemaCache::default(); let mut cache = SchemaCache::default();
cache.tables.push("Customers".into()); cache.tables.push("Customers".into());
cache.columns.push("name".into()); cache.columns.push("name".into());
cache cache.table_columns.insert(
.table_columns "Customers".into(),
.insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]); vec![TableColumn::new("name", Type::Text)],
);
let input = ": select name from Customers"; let input = ": select name from Customers";
let view = "select name from Customers"; let view = "select name from Customers";
let offset = 2; // ": " let offset = 2; // ": "
@@ -1362,9 +1367,10 @@ mod tests {
let mut cache = crate::completion::SchemaCache::default(); let mut cache = crate::completion::SchemaCache::default();
cache.tables.push("users".to_string()); cache.tables.push("users".to_string());
cache.columns.push("email".to_string()); cache.columns.push("email".to_string());
cache cache.table_columns.insert(
.table_columns "users".to_string(),
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]); vec![TableColumn::new("email", Type::Text)],
);
cache cache
} }
@@ -1392,7 +1398,10 @@ mod tests {
} }
// Tab candidates remain available (completion is independent). // Tab candidates remain available (completion is independent).
let comp = crate::completion::candidates_at_cursor_in_mode( 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"); .expect("completion remains available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
@@ -1424,7 +1433,10 @@ mod tests {
&hint, &hint,
Some(AmbientHint::Prose(p)) if p.contains("row count") 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; use crate::dsl::types::Type;
let mut cache = SchemaCache::default(); let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string()); cache.tables.push("Customers".to_string());
let tc = vec![ let tc = vec![TableColumn {
TableColumn { name: "Age".to_string(),
name: "Age".to_string(), user_type: Type::Int,
user_type: Type::Int, not_null: false,
not_null: false, has_default: false,
has_default: false, }];
},
];
for c in &tc { for c in &tc {
cache.columns.push(c.name.clone()); cache.columns.push(c.name.clone());
} }
@@ -1551,9 +1561,7 @@ mod tests {
p.contains("No such") && p.contains("Agx"), p.contains("No such") && p.contains("Agx"),
"a genuine column typo before FROM must warn at typing time; got: {p:?}", "a genuine column typo before FROM must warn at typing time; got: {p:?}",
), ),
other => panic!( other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",),
"`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 // ADR-0022 Amendment 1: advanced-mode ambient assistance
// surfaces SQL completion candidates (here the FROM-slot // surfaces SQL completion candidates (here the FROM-slot
// table) instead of the simple-mode "this is SQL" gate. // table) instead of the simple-mode "this is SQL" gate.
let cache = let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from "; let input = "select * from ";
match ambient_hint_in_mode( match ambient_hint_in_mode(
input, input,
@@ -1678,10 +1685,7 @@ mod tests {
// `INSERT … (` column list. (The simple-mode DSL value-slot // `INSERT … (` column list. (The simple-mode DSL value-slot
// prose is a separate surface; this pins the §8 advanced claim.) // prose is a separate surface; this pins the §8 advanced claim.)
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let set_slot = "update Customers set "; let set_slot = "update Customers set ";
match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) { 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() { fn simple_mode_ambient_does_not_surface_sql_candidates() {
// The simple-mode entry point keeps gating SQL — advanced // The simple-mode entry point keeps gating SQL — advanced
// assistance is opt-in via mode, never leaked into simple. // assistance is opt-in via mode, never leaked into simple.
let cache = let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from "; let input = "select * from ";
let hint = ambient_hint_in_mode( let hint =
input, ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple);
input.len(),
None,
&cache,
crate::mode::Mode::Simple,
);
let offers_table = matches!( let offers_table = matches!(
&hint, &hint,
Some(AmbientHint::Candidates { items, .. }) Some(AmbientHint::Candidates { items, .. })
@@ -1733,8 +1731,7 @@ mod tests {
fn f1_mid_typed_table_prefix_shows_completion_not_error() { fn f1_mid_typed_table_prefix_shows_completion_not_error() {
// "select * from c" — `c` prefix-matches `Customers`. The // "select * from c" — `c` prefix-matches `Customers`. The
// hint must offer the completion, not "no such table c". // hint must offer the completion, not "no such table c".
let cache = let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode( match ambient_hint_in_mode(
"select * from c", "select * from c",
"select * from c".len(), "select * from c".len(),
@@ -1753,8 +1750,7 @@ mod tests {
#[test] #[test]
fn f1_genuinely_unknown_table_still_shows_error() { fn f1_genuinely_unknown_table_still_shows_error() {
// "zzz" matches no table prefix — the error must still show. // "zzz" matches no table prefix — the error must still show.
let cache = let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode( match ambient_hint_in_mode(
"select * from zzz", "select * from zzz",
"select * from zzz".len(), "select * from zzz".len(),
@@ -1773,8 +1769,7 @@ mod tests {
fn f1_simple_mode_dsl_mid_typed_table_completes() { fn f1_simple_mode_dsl_mid_typed_table_completes() {
// The same shadowing affects DSL commands in simple mode: // The same shadowing affects DSL commands in simple mode:
// "show data c" must offer Customers, not "no such table c". // "show data c" must offer Customers, not "no such table c".
let cache = let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode( match ambient_hint_in_mode(
"show data c", "show data c",
"show data c".len(), "show data c".len(),
@@ -1804,7 +1799,12 @@ mod tests {
cache.columns.push("order_col".to_string()); cache.columns.push("order_col".to_string());
cache.table_columns.insert( cache.table_columns.insert(
"Orders".to_string(), "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( let comp = crate::completion::candidates_at_cursor_in_mode(
@@ -1846,9 +1846,7 @@ mod tests {
for c in &columns { for c in &columns {
cache.columns.push(c.name.clone()); cache.columns.push(c.name.clone());
} }
cache cache.table_columns.insert(table.to_string(), columns);
.table_columns
.insert(table.to_string(), columns);
cache cache
} }
@@ -1860,7 +1858,11 @@ mod tests {
("Customers", &[("id", Type::Serial), ("name", Type::Text)]), ("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
( (
"Products", "Products",
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)], &[
("id", Type::Serial),
("name", Type::Text),
("price", Type::Decimal),
],
), ),
( (
"OrderLines", "OrderLines",
@@ -1873,13 +1875,19 @@ mod tests {
), ),
( (
"Orders", "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 { for (t, cols) in tables {
cache.tables.push((*t).to_string()); cache.tables.push((*t).to_string());
let tc: Vec<TableColumn> = let tc: Vec<TableColumn> = cols
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect(); .iter()
.map(|(n, ty)| TableColumn::new(*n, *ty))
.collect();
for c in &tc { for c in &tc {
cache.columns.push(c.name.clone()); cache.columns.push(c.name.clone());
} }
@@ -1914,17 +1922,11 @@ mod tests {
#[test] #[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() { fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values ("; let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
assert!( assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",);
p.contains("integer"),
"expected int-slot prose, got: {p:?}",
);
} }
other => panic!("expected Prose, got {other:?}"), other => panic!("expected Prose, got {other:?}"),
} }
@@ -1942,7 +1944,11 @@ mod tests {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns(
"Customers", "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')"; let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
@@ -1966,10 +1972,7 @@ mod tests {
// A valid simple-mode DSL command gets no advanced pointer — // A valid simple-mode DSL command gets no advanced pointer —
// it isn't an error, and there is nothing to switch modes for. // it isn't an error, and there is nothing to switch modes for.
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
"Customers",
&[("id", Type::Serial), ("Name", Type::Text)],
);
let input = "insert into Customers values ('Alice')"; let input = "insert into Customers values ('Alice')";
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) { if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
assert!( assert!(
@@ -2010,10 +2013,7 @@ mod tests {
#[test] #[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() { fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values (1, "; let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
@@ -2029,10 +2029,8 @@ mod tests {
#[test] #[test]
fn ambient_hint_at_update_set_shows_per_column_prose() { fn ambient_hint_at_update_set_shows_per_column_prose() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache =
"Customers", schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]);
&[("id", Type::Int), ("Birthday", Type::Date)],
);
let input = "update Customers set Birthday="; let input = "update Customers set Birthday=";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
@@ -2057,9 +2055,7 @@ mod tests {
// hasn't committed), the Optional propagates Incomplete // hasn't committed), the Optional propagates Incomplete
// and the user sees no error overlay until they submit. // and the user sees no error overlay until they submit.
assert_eq!( assert_eq!(
classify_input( classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"),
"insert into Orders (id, CustId, Total) values (42, 89, 17.59"
),
InputState::IncompleteAtEof, InputState::IncompleteAtEof,
); );
assert_eq!( assert_eq!(
@@ -2071,18 +2067,12 @@ mod tests {
#[test] #[test]
fn ambient_hint_at_insert_first_value_mentions_column_name() { fn ambient_hint_at_insert_first_value_mentions_column_name() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values ("; let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
assert!(p.contains("id"), "expected column name `id`, got {p:?}"); assert!(p.contains("id"), "expected column name `id`, got {p:?}");
assert!( assert!(p.contains("integer"), "expected int prose, got {p:?}",);
p.contains("integer"),
"expected int prose, got {p:?}",
);
} }
other => panic!("expected Prose, got {other:?}"), other => panic!("expected Prose, got {other:?}"),
} }
@@ -2091,10 +2081,7 @@ mod tests {
#[test] #[test]
fn ambient_hint_at_update_set_mentions_column_name() { fn ambient_hint_at_update_set_mentions_column_name() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let input = "update Customers set Email="; let input = "update Customers set Email=";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
@@ -2131,10 +2118,7 @@ mod tests {
#[test] #[test]
fn ambient_hint_at_second_insert_value_mentions_second_column() { fn ambient_hint_at_second_insert_value_mentions_second_column() {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values (1, "; let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
@@ -2165,14 +2149,20 @@ mod tests {
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cases: &[(&[(&str, Type)], &str)] = &[ let cases: &[(&[(&str, Type)], &str)] = &[
// string first value (the report's case): first col text. // 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. // 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. // 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 { for (cols, input) in cases {
let cache = schema_with_columns("Customers", cols); let cache = schema_with_columns("Customers", cols);
@@ -2232,10 +2222,7 @@ mod tests {
// is nothing left to fill. Guards against over-correcting the // is nothing left to fill. Guards against over-correcting the
// fix into never suggesting the close paren. // fix into never suggesting the close paren.
use crate::dsl::types::Type; use crate::dsl::types::Type;
let cache = schema_with_columns( let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]);
"Customers",
&[("Name", Type::Text), ("Age", Type::Int)],
);
let input = "insert into Customers values ('Oli', 52"; let input = "insert into Customers values ('Oli', 52";
match ambient_hint(input, input.len(), None, &cache) { match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
@@ -2384,10 +2371,7 @@ mod tests {
match ambient_hint("show data Missing", 17, None, &cache) { match ambient_hint("show data Missing", 17, None, &cache) {
Some(AmbientHint::Prose(p)) => { Some(AmbientHint::Prose(p)) => {
assert!(p.contains("Missing"), "got {p:?}"); assert!(p.contains("Missing"), "got {p:?}");
assert!( assert!(p.to_lowercase().contains("no such table"), "got {p:?}",);
p.to_lowercase().contains("no such table"),
"got {p:?}",
);
} }
other => panic!("expected Prose, got {other:?}"), other => panic!("expected Prose, got {other:?}"),
} }
@@ -2440,8 +2424,7 @@ mod tests {
use crate::dsl::types::Type; use crate::dsl::types::Type;
// Two type-mismatch WARNINGs; the hint names the column // Two type-mismatch WARNINGs; the hint names the column
// whose offending literal the cursor sits in. // whose offending literal the cursor sits in.
let cache = let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let input = "delete from Events where a = 'x' or b = 'y'"; let input = "delete from Events where a = 'x' or b = 'y'";
let on_x = input.find("'x'").expect("'x' literal") + 1; let on_x = input.find("'x'").expect("'x' literal") + 1;
let on_y = input.find("'y'").expect("'y' literal") + 1; let on_y = input.find("'y'").expect("'y' literal") + 1;
@@ -2460,8 +2443,16 @@ mod tests {
inserted_range: (5, 5), inserted_range: (5, 5),
original_text: String::new(), original_text: String::new(),
candidates: vec![ candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, Candidate {
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, 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, selection_idx: 1,
}; };
@@ -2494,8 +2485,16 @@ mod tests {
// produce — proves the memo's list is being used, // produce — proves the memo's list is being used,
// not a recomputed one. // not a recomputed one.
candidates: vec![ candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, Candidate {
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, 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, selection_idx: 1,
}; };
@@ -2564,10 +2563,7 @@ mod tests {
fn classify_trailing_whitespace_does_not_create_definite_error() { fn classify_trailing_whitespace_does_not_create_definite_error() {
// Trailing whitespace alone shouldn't promote an // Trailing whitespace alone shouldn't promote an
// incomplete-at-EOF state into a definite error. // incomplete-at-EOF state into a definite error.
assert_eq!( assert_eq!(classify_input("create "), InputState::IncompleteAtEof,);
classify_input("create "),
InputState::IncompleteAtEof,
);
} }
#[test] #[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()))?; .with_context(|| format!("create log directory {}", parent.display()))?;
} }
let file = open_log_file(&chosen)?; let file = open_log_file(&chosen)?;
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG") let filter =
.unwrap_or_else(|_| EnvFilter::new("info")); EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
let layer = fmt::layer() let layer = fmt::layer()
.with_writer(file) .with_writer(file)
.with_ansi(false) .with_ansi(false)
@@ -95,8 +95,7 @@ fn home_dir() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("HOME") { if let Some(p) = std::env::var_os("HOME") {
return Some(PathBuf::from(p)); return Some(PathBuf::from(p));
} }
if let (Some(drive), Some(path)) = if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{ {
let mut combined = PathBuf::from(drive); let mut combined = PathBuf::from(drive);
combined.push(path); combined.push(path);
+1 -1
View File
@@ -1,6 +1,6 @@
use std::process::ExitCode; use std::process::ExitCode;
use rdbms_playground::cli::{help_text, version_text, Args}; use rdbms_playground::cli::{Args, help_text, version_text};
use rdbms_playground::{logging, runtime}; use rdbms_playground::{logging, runtime};
fn main() -> ExitCode { fn main() -> ExitCode {
+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 /// A `detail` matching no marker renders neutral — the engine's
/// plan vocabulary may grow (ADR-0028 §4). /// plan vocabulary may grow (ADR-0028 §4).
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[ const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex), (
"USING AUTOMATIC COVERING INDEX",
OutputStyleClass::AutomaticIndex,
),
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex), ("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
("USING COVERING INDEX", OutputStyleClass::Efficient), ("USING COVERING INDEX", OutputStyleClass::Efficient),
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient), ("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
@@ -225,8 +228,7 @@ fn render_plan_subtree(
emitted: &mut HashSet<i64>, emitted: &mut HashSet<i64>,
mode: Mode, mode: Mode,
) { ) {
let children: Vec<&ExplainRow> = let children: Vec<&ExplainRow> = rows.iter().filter(|r| r.parent == parent).collect();
rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1); let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() { for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) { if !emitted.insert(row.id) {
@@ -235,8 +237,7 @@ fn render_plan_subtree(
let is_last = idx == last_idx; let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " }; let connector = if is_last { "└─ " } else { "├─ " };
out.push(plan_node_line(prefix, connector, &row.detail, mode)); out.push(plan_node_line(prefix, connector, &row.detail, mode));
let child_prefix = let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
format!("{prefix}{}", if is_last { " " } else { "│ " });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode); 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 { const fn alignment_for(ty: Option<Type>) -> Alignment {
match ty { match ty {
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right, Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
Some(Type::Text) Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
| Some(Type::Bool) | Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left,
| 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 /// Render a single bordered table given header cells, body
/// rows, and per-column alignment. Outer frame + /// rows, and per-column alignment. Outer frame +
/// header-underline only. /// header-underline only.
fn render_table( fn render_table(headers: &[String], body: &[Vec<String>], alignments: &[Alignment]) -> Vec<String> {
headers: &[String],
body: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
debug_assert_eq!(headers.len(), alignments.len()); debug_assert_eq!(headers.len(), alignments.len());
// Compute column widths: max(header, all body cells). // 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. // The vertical bus spans the full range of endpoint rows.
let bounds = child_rows let bounds = child_rows.iter().chain(parent_rows).copied().fold(
.iter() None,
.chain(parent_rows) |acc: Option<(usize, usize)>, r| {
.copied()
.fold(None, |acc: Option<(usize, usize)>, r| {
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r)))) Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
}); },
);
if let Some((top, bot)) = bounds if let Some((top, bot)) = bounds
&& i >= top && i >= top
&& i <= bot && i <= bot
@@ -1138,7 +1129,10 @@ mod tests {
assert!(out.contains("customer_id ●"), "FK marker:\n{out}"); assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}"); assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
assert!(out.contains('▶'), "arrowhead:\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!( assert!(
out.contains("on delete cascade · on update no action"), out.contains("on delete cascade · on update no action"),
"actions:\n{out}" "actions:\n{out}"
@@ -1237,7 +1231,10 @@ mod tests {
let (r_out, r_in) = blank_rels(); let (r_out, r_in) = blank_rels();
let region = TableDescription { let region = TableDescription {
name: "Region".to_string(), 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, outbound_relationships: r_out,
inbound_relationships: r_in, inbound_relationships: r_in,
indexes: Vec::new(), indexes: Vec::new(),
@@ -1277,7 +1274,10 @@ mod tests {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}"); 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!( assert!(
text.contains("(country, region_code) ▶ Region.(country, code)"), text.contains("(country, region_code) ▶ Region.(country, code)"),
"pairing line:\n{text}", "pairing line:\n{text}",
@@ -1412,11 +1412,7 @@ mod tests {
let data = DataResult { let data = DataResult {
table_name: "Customers".to_string(), table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()], columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
column_types: vec![ column_types: vec![Some(Type::Serial), Some(Type::Text), Some(Type::Text)],
Some(Type::Serial),
Some(Type::Text),
Some(Type::Text),
],
rows: vec![ rows: vec![
vec![ vec![
Some("1".to_string()), Some("1".to_string()),
@@ -1634,7 +1630,10 @@ mod tests {
assert!(out.contains("Indexes:"), "got:\n{out}"); assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}"); assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker. // 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] #[test]
@@ -1677,7 +1676,10 @@ mod tests {
indexes: Vec::new(), indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]], unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![ 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 { crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()), name: Some("a_lt_b".to_string()),
expr: "a <> 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>`. // (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("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 < 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] #[test]
@@ -1732,17 +1737,37 @@ mod tests {
let plan = QueryPlan { let plan = QueryPlan {
display_sql: "SELECT 1".to_string(), display_sql: "SELECT 1".to_string(),
rows: vec![ rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() }, ExplainRow {
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() }, id: 1,
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() }, 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); let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes. // display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4); assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root")); assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text); assert!(
assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text); 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 // The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last). // by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root")); assert!(lines[1].text.starts_with("└─ root"));
@@ -1775,7 +1800,10 @@ mod tests {
fn render_explain_plan_colours_a_full_scan_expensive() { fn render_explain_plan_colours_a_full_scan_expensive() {
let plan = one_node_plan("SCAN Customers"); let plan = one_node_plan("SCAN Customers");
let lines = render_explain_plan(&plan, Mode::Simple); 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). // The table name stays neutral (ADR-0028 §6).
assert_eq!( assert_eq!(
span_class_for(&lines[1], "Customers"), span_class_for(&lines[1], "Customers"),
@@ -1801,8 +1829,7 @@ mod tests {
#[test] #[test]
fn render_explain_plan_flags_an_automatic_index() { fn render_explain_plan_flags_an_automatic_index() {
let plan = let plan = one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let lines = render_explain_plan(&plan, Mode::Simple); let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!( assert_eq!(
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"), 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:?}")), other => Err(format!("expected date/datetime (text), got {other:?}")),
}, },
Type::Blob => match value { 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:?}")), other => Err(format!("expected blob, got {other:?}")),
}, },
Type::Serial => match value { Type::Serial => match value {
@@ -169,7 +171,11 @@ fn format_real(f: f64) -> String {
if f.is_nan() { if f.is_nan() {
"nan".to_string() "nan".to_string()
} else if f.is_infinite() { } 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 { } else {
// Default `{}` formatting on f64 emits a shortest // Default `{}` formatting on f64 emits a shortest
// round-tripping decimal — exactly what the ADR asks // round-tripping decimal — exactly what the ADR asks
@@ -318,8 +324,7 @@ fn parse_field(bytes: &[u8]) -> Result<(RawCell, usize), CsvError> {
_ => i += 1, _ => i += 1,
} }
} }
let content = let content = String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
Ok(( Ok((
RawCell { RawCell {
content, content,
@@ -435,7 +440,10 @@ mod tests {
name: "T".to_string(), name: "T".to_string(),
columns: vec![col("n", Type::Int), col("r", Type::Real)], columns: vec![col("n", Type::Int), col("r", Type::Real)],
rows: vec![ 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)], vec![CellValue::Integer(-7), CellValue::Real(0.0)],
], ],
}) })
@@ -452,10 +460,7 @@ mod tests {
let body = serialize_table(&TableSnapshot { let body = serialize_table(&TableSnapshot {
name: "T".to_string(), name: "T".to_string(),
columns: vec![col("b", Type::Bool)], columns: vec![col("b", Type::Bool)],
rows: vec![ rows: vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(0)]],
vec![CellValue::Integer(1)],
vec![CellValue::Integer(0)],
],
}) })
.unwrap(); .unwrap();
let s = String::from_utf8(body).unwrap(); let s = String::from_utf8(body).unwrap();
@@ -555,13 +560,21 @@ mod tests {
let body = serialize_table(&table).unwrap(); let body = serialize_table(&table).unwrap();
let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap(); let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap();
let row = &parsed.rows[0]; 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() { match decode_cell(Type::Real, &row[1]).unwrap() {
CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12), CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12),
other => panic!("got {other:?}"), other => panic!("got {other:?}"),
} }
assert!(matches!(decode_cell(Type::Bool, &row[2]).unwrap(), CellValue::Integer(1))); assert!(matches!(
assert!(matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")); 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] #[test]
@@ -572,7 +585,10 @@ mod tests {
#[test] #[test]
fn decode_cell_reports_friendly_error_for_bad_int() { 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"); let err = decode_cell(Type::Int, &cell).expect_err("must error");
assert!(err.contains("integer")); assert!(err.contains("integer"));
assert!(err.contains("abc")); assert!(err.contains("abc"));
+33 -18
View File
@@ -108,10 +108,7 @@ pub(super) fn read_recent_sources(
}); });
} }
}; };
let mut sources: Vec<String> = body let mut sources: Vec<String> = body.lines().filter_map(parse_record_source).collect();
.lines()
.filter_map(parse_record_source)
.collect();
if sources.len() > max_n { if sources.len() > max_n {
let skip = sources.len() - max_n; let skip = sources.len() - max_n;
sources.drain(0..skip); sources.drain(0..skip);
@@ -187,12 +184,26 @@ fn looks_like_iso8601(s: &str) -> bool {
return false; return false;
} }
let digit = |i: usize| b[i].is_ascii_digit(); let digit = |i: usize| b[i].is_ascii_digit();
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-' digit(0)
&& digit(5) && digit(6) && b[7] == b'-' && digit(1)
&& digit(8) && digit(9) && b[10] == b'T' && digit(2)
&& digit(11) && digit(12) && b[13] == b':' && digit(3)
&& digit(14) && digit(15) && b[16] == b':' && b[4] == b'-'
&& digit(17) && digit(18) && b[19] == b'Z' && 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 { fn unescape_command(s: &str) -> String {
@@ -321,10 +332,8 @@ mod tests {
#[test] #[test]
fn parse_journal_record_ok_extracts_unescaped_source() { fn parse_journal_record_ok_extracts_unescaped_source() {
let rec = parse_journal_record( let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)")
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)", .expect("valid ok journal record");
)
.expect("valid ok journal record");
assert!(rec.status_is_ok); assert!(rec.status_is_ok);
assert_eq!(rec.source, "create table T with pk id(int)"); 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() { fn parse_journal_record_preserves_pipe_in_source() {
// `|` is not escaped by the writer (it's a valid SQL char); // `|` is not escaped by the writer (it's a valid SQL char);
// `splitn(3, '|')` keeps everything after the second `|`. // `splitn(3, '|')` keeps everything after the second `|`.
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t") let rec =
.expect("ok record"); 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"); assert_eq!(rec.source, "select 'a|b' from t");
} }
@@ -406,7 +415,10 @@ mod tests {
#[test] #[test]
fn iso8601_known_seconds() { fn iso8601_known_seconds() {
assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z"); 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] #[test]
@@ -437,7 +449,10 @@ mod tests {
.collect(); .collect();
std::fs::write(&path, body).unwrap(); std::fs::write(&path, body).unwrap();
let got = read_recent_sources(&path, 3).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] #[test]
+37 -43
View File
@@ -82,7 +82,10 @@ impl Default for MigratorRegistry {
#[derive(Debug)] #[derive(Debug)]
pub enum MigrateError { pub enum MigrateError {
VersionParse(String), VersionParse(String),
NewerThanSupported { file: u32, latest: u32 }, NewerThanSupported {
file: u32,
latest: u32,
},
NoMigratorForVersion(u32), NoMigratorForVersion(u32),
StepFailed { StepFailed {
from: u32, from: u32,
@@ -108,10 +111,9 @@ impl std::fmt::Display for MigrateError {
file = file, file = file,
latest = latest, latest = latest,
)), )),
Self::NoMigratorForVersion(v) => f.write_str(&crate::t!( Self::NoMigratorForVersion(v) => {
"persistence.migrate.no_migrator", f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,))
version = v, }
)),
Self::StepFailed { from, to, source } => f.write_str(&crate::t!( Self::StepFailed { from, to, source } => f.write_str(&crate::t!(
"persistence.migrate.step_failed", "persistence.migrate.step_failed",
from = from, from = from,
@@ -192,8 +194,11 @@ pub fn migrate_to_latest(
// Write the .bak before any transformation runs so a // Write the .bak before any transformation runs so a
// mid-migration crash leaves the original recoverable. // mid-migration crash leaves the original recoverable.
let bak_path = let bak_path = project_path.join(format!(
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version)); "{}.v{}.bak",
crate::project::PROJECT_YAML,
file_version
));
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io { std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
path: bak_path.clone(), path: bak_path.clone(),
source, source,
@@ -214,8 +219,8 @@ pub fn migrate_to_latest(
// Sanity: the new body must declare the next version. // Sanity: the new body must declare the next version.
// If a migrator forgets to bump, we'd loop endlessly // If a migrator forgets to bump, we'd loop endlessly
// through the chain — catch it here. // through the chain — catch it here.
let advertised = read_version(&next_body) let advertised =
.map_err(|e| MigrateError::BadOutput(e.to_string()))?; read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?;
if advertised != v + 1 { if advertised != v + 1 {
return Err(MigrateError::BadOutput(format!( return Err(MigrateError::BadOutput(format!(
"v{v}→v{} migrator left version field at {advertised}", "v{v}→v{} migrator left version field at {advertised}",
@@ -281,9 +286,8 @@ fn read_version(body: &str) -> Result<u32, MigrateError> {
struct VersionOnly { struct VersionOnly {
version: u32, version: u32,
} }
let v: VersionOnly = serde_norway::from_str(body).map_err(|e| { let v: VersionOnly =
MigrateError::VersionParse(e.to_string()) serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?;
})?;
Ok(v.version) Ok(v.version)
} }
@@ -309,12 +313,8 @@ mod tests {
#[test] #[test]
fn no_migration_runs_when_body_already_latest() { fn no_migration_runs_when_body_already_latest() {
let tmp = tempdir(); let tmp = tempdir();
let outcome = migrate_to_latest( let outcome =
&v1_body(), migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
&MigratorRegistry::production(),
tmp.path(),
)
.unwrap();
assert_eq!(outcome.body, v1_body()); assert_eq!(outcome.body, v1_body());
assert_eq!(outcome.migrated_from, None); assert_eq!(outcome.migrated_from, None);
// No .bak written when nothing migrated. // No .bak written when nothing migrated.
@@ -328,7 +328,13 @@ mod tests {
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp")) let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
.expect_err("must reject"); .expect_err("must reject");
assert!( assert!(
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }), matches!(
err,
MigrateError::NewerThanSupported {
file: 99,
latest: 1
}
),
"got: {err:?}", "got: {err:?}",
); );
} }
@@ -366,12 +372,7 @@ mod tests {
#[test] #[test]
fn migrate_runs_chain_and_writes_bak() { fn migrate_runs_chain_and_writes_bak() {
let tmp = tempdir(); let tmp = tempdir();
let outcome = migrate_to_latest( let outcome = migrate_to_latest(&v1_body(), &registry_with_v1_to_v2(), tmp.path()).unwrap();
&v1_body(),
&registry_with_v1_to_v2(),
tmp.path(),
)
.unwrap();
assert_eq!(outcome.migrated_from, Some(1)); assert_eq!(outcome.migrated_from, Some(1));
assert!(outcome.body.contains("version: 2")); assert!(outcome.body.contains("version: 2"));
let bak = tmp.path().join("project.yaml.v1.bak"); let bak = tmp.path().join("project.yaml.v1.bak");
@@ -396,11 +397,8 @@ mod tests {
let tmp = tempdir(); let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml"); let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap(); std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated( let outcome =
tmp.path(), ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
&MigratorRegistry::production(),
)
.unwrap();
assert_eq!(outcome.migrated_from, None); assert_eq!(outcome.migrated_from, None);
// File unchanged. // File unchanged.
let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
@@ -413,36 +411,32 @@ mod tests {
let tmp = tempdir(); let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml"); let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap(); std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated( let outcome = ensure_project_yaml_migrated(tmp.path(), &registry_with_v1_to_v2()).unwrap();
tmp.path(),
&registry_with_v1_to_v2(),
)
.unwrap();
assert_eq!(outcome.migrated_from, Some(1)); assert_eq!(outcome.migrated_from, Some(1));
let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
assert!(on_disk.contains("version: 2"), "got: {on_disk}"); assert!(on_disk.contains("version: 2"), "got: {on_disk}");
let bak = tmp.path().join("project.yaml.v1.bak"); let bak = tmp.path().join("project.yaml.v1.bak");
assert!(bak.exists()); 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] #[test]
fn ensure_yaml_migrated_handles_missing_yaml() { fn ensure_yaml_migrated_handles_missing_yaml() {
let tmp = tempdir(); let tmp = tempdir();
// No project.yaml exists. // No project.yaml exists.
let outcome = ensure_project_yaml_migrated( let outcome =
tmp.path(), ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
&MigratorRegistry::production(),
)
.unwrap();
assert_eq!(outcome.migrated_from, None); assert_eq!(outcome.migrated_from, None);
assert!(outcome.body.is_empty()); assert!(outcome.body.is_empty());
} }
#[test] #[test]
fn migrator_that_returns_internal_error_propagates() { fn migrator_that_returns_internal_error_propagates() {
let bad: MigrateFn = let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string()));
|_| Err(MigrateError::VersionParse("simulated".to_string()));
let registry = MigratorRegistry { let registry = MigratorRegistry {
migrators: vec![bad], migrators: vec![bad],
}; };
+19 -19
View File
@@ -368,12 +368,11 @@ impl Persistence {
path: data_dir.clone(), path: data_dir.clone(),
source, source,
})?; })?;
let body = let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode { kind: "CSV",
kind: "CSV", path: data_dir.join(format!("{}.csv", table.name)),
path: data_dir.join(format!("{}.csv", table.name)), message,
message, })?;
})?;
atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body) atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body)
} }
@@ -406,11 +405,8 @@ impl Persistence {
) -> Result<(), PersistenceError> { ) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG); let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_OK, advanced); let status = history::status_token(history::STATUS_OK, advanced);
let line = history::format_record_with_status( let line =
command_text, history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
history::utc_iso8601_now(),
&status,
);
debug!( debug!(
len = command_text.len(), len = command_text.len(),
advanced, "persist: append ok record to history.log" advanced, "persist: append ok record to history.log"
@@ -432,11 +428,8 @@ impl Persistence {
) -> Result<(), PersistenceError> { ) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG); let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_ERR, advanced); let status = history::status_token(history::STATUS_ERR, advanced);
let line = history::format_record_with_status( let line =
command_text, history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
history::utc_iso8601_now(),
&status,
);
debug!( debug!(
len = command_text.len(), len = command_text.len(),
advanced, "persist: append err record to history.log" advanced, "persist: append err record to history.log"
@@ -531,8 +524,14 @@ mod tests {
#[test] #[test]
fn extension_with_tmp_appends_to_existing_extension() { fn extension_with_tmp_appends_to_existing_extension() {
assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp"); assert_eq!(
assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp"); 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"); assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp");
} }
@@ -600,7 +599,8 @@ mod tests {
fn append_history_creates_and_appends() { fn append_history_creates_and_appends() {
let dir = tempdir(); let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf()); 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(); p.append_history("insert into Foo (1)", false).unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap(); let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect(); 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 // Scalar text that looks like a YAML keyword needs quoting
// even if every character is safe. // 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; return true;
} }
s.chars().any(|c| !is_safe_yaml_char(c)) 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 { for t in raw.tables {
let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len()); let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len());
for c in t.columns { for c in t.columns {
let user_type = c.user_type.parse::<Type>().map_err(|_| { let user_type = c
YamlError::UnknownType { .user_type
.parse::<Type>()
.map_err(|_| YamlError::UnknownType {
table: t.name.clone(), table: t.name.clone(),
column: c.name.clone(), column: c.name.clone(),
raw: c.user_type.clone(), raw: c.user_type.clone(),
} })?;
})?;
columns.push(ColumnSchema { columns.push(ColumnSchema {
name: c.name, name: c.name,
user_type, user_type,
@@ -308,7 +312,11 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
primary_key: t.primary_key, primary_key: t.primary_key,
columns, columns,
unique_constraints: t.unique_constraints, 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()); 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 { impl std::fmt::Display for YamlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Syntax(msg) => f.write_str(&crate::t!( Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)),
"persistence.yaml.syntax",
detail = msg,
)),
Self::UnsupportedVersion(v) => f.write_str(&crate::t!( Self::UnsupportedVersion(v) => f.write_str(&crate::t!(
"persistence.yaml.unsupported_version", "persistence.yaml.unsupported_version",
version = v, version = v,
@@ -395,10 +400,9 @@ impl std::fmt::Display for YamlError {
column = column, column = column,
raw = raw, raw = raw,
)), )),
Self::UnknownAction(raw) => f.write_str(&crate::t!( Self::UnknownAction(raw) => {
"persistence.yaml.unknown_action", f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,))
raw = raw, }
)),
} }
} }
} }
@@ -545,8 +549,22 @@ mod tests {
name: "Customers".to_string(), name: "Customers".to_string(),
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
columns: vec![ columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None }, ColumnSchema {
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None }, 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(), unique_constraints: Vec::new(),
check_constraints: Vec::new(), check_constraints: Vec::new(),
@@ -555,8 +573,22 @@ mod tests {
name: "Orders".to_string(), name: "Orders".to_string(),
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
columns: vec![ columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None }, ColumnSchema {
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, 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(), unique_constraints: Vec::new(),
check_constraints: Vec::new(), check_constraints: Vec::new(),
@@ -798,15 +830,33 @@ indexes:
name: "T".to_string(), name: "T".to_string(),
primary_key: vec![], primary_key: vec![],
columns: vec![ columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, ColumnSchema {
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, name: "a".to_string(),
ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, 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()]], unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![ check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")],
TableCheck::unnamed("a < b"),
TableCheck::unnamed("b < c"),
],
}], }],
relationships: vec![], relationships: vec![],
indexes: vec![], indexes: vec![],
@@ -830,12 +880,29 @@ indexes:
name: "T".to_string(), name: "T".to_string(),
primary_key: vec!["id".to_string()], primary_key: vec!["id".to_string()],
columns: vec![ columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, ColumnSchema {
ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, 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![], unique_constraints: vec![],
check_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"), TableCheck::unnamed("qty < 1000"),
], ],
}], }],
@@ -844,7 +911,10 @@ indexes:
}; };
let body = serialize_schema(&snap); let body = serialize_schema(&snap);
let parsed = parse_schema(&body).expect("parse schema"); 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] #[test]
@@ -968,8 +1038,22 @@ relationships:
name: "Items".to_string(), name: "Items".to_string(),
primary_key: vec!["a".to_string(), "b".to_string()], primary_key: vec!["a".to_string(), "b".to_string()],
columns: vec![ columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, ColumnSchema {
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None }, 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(), unique_constraints: Vec::new(),
check_constraints: Vec::new(), check_constraints: Vec::new(),
@@ -1019,12 +1103,10 @@ relationships:
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n"; let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
assert_eq!(parse_stored_mode(absent), None); assert_eq!(parse_stored_mode(absent), None);
let explicit_simple = let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
"version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple)); assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
let advanced = let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
"version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced)); 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. /// Uses `sysinfo` to query the OS process table.
fn pid_is_alive(pid: u32) -> bool { fn pid_is_alive(pid: u32) -> bool {
let mut sys = System::new(); 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() sys.process(Pid::from_u32(pid)).is_some()
} }
@@ -211,7 +214,10 @@ mod tests {
// The first lock writes our own PID; a second attempt // The first lock writes our own PID; a second attempt
// should refuse because the PID is alive on this host. // should refuse because the PID is alive on this host.
let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition"); 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] #[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` /// a moved/deleted directory is the kind of error `--resume`
/// is supposed to surface clearly, not paper over by /// is supposed to surface clearly, not paper over by
/// resolving symlinks at write time. /// resolving symlinks at write time.
pub fn write_last_project( pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> {
data_root: &Path,
project_path: &Path,
) -> std::io::Result<()> {
fs::create_dir_all(data_root)?; fs::create_dir_all(data_root)?;
let final_path = data_root.join(LAST_PROJECT_FILE); let final_path = data_root.join(LAST_PROJECT_FILE);
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp")); 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 { if let Some(p) = override_dir {
return Ok(p.to_path_buf()); return Ok(p.to_path_buf());
} }
let dirs = ProjectDirs::from("", "", "rdbms-playground").ok_or( let dirs =
ProjectError::DataRootUnavailable, ProjectDirs::from("", "", "rdbms-playground").ok_or(ProjectError::DataRootUnavailable)?;
)?;
Ok(dirs.data_dir().to_path_buf()) Ok(dirs.data_dir().to_path_buf())
} }
@@ -255,21 +251,16 @@ pub enum ProjectError {
impl std::fmt::Display for ProjectError { impl std::fmt::Display for ProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::DataRootUnavailable => { Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")),
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!( Self::Io { path, source } => f.write_str(&crate::t!(
"project.io", "project.io",
path = path.display(), path = path.display(),
@@ -609,11 +600,10 @@ pub fn safely_delete_temp_project(
// 2. Canonicalize for the containment check. We do this // 2. Canonicalize for the containment check. We do this
// only after the symlink-at-top check so we can't be // only after the symlink-at-top check so we can't be
// tricked by a top-level symlink. // tricked by a top-level symlink.
let project_canon = let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io { path: project_path.to_path_buf(),
path: project_path.to_path_buf(), source,
source, })?;
})?;
// 3. Containment: canonical path must be inside the // 3. Containment: canonical path must be inside the
// canonical data-root projects dir. // canonical data-root projects dir.
@@ -848,7 +838,10 @@ mod tests {
assert!(gi.contains("/playground.db")); assert!(gi.contains("/playground.db"));
assert!(gi.contains("/.rdbms-playground.lock")); assert!(gi.contains("/.rdbms-playground.lock"));
assert!(gi.contains("/.snapshots/"), "undo ring should be ignored"); 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] #[test]
@@ -890,7 +883,10 @@ mod tests {
let target = tmp.path().join("MyProject"); let target = tmp.path().join("MyProject");
fs::create_dir(&target).unwrap(); fs::create_dir(&target).unwrap();
let err = Project::create_named(&target).expect_err("must refuse"); 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] #[test]
@@ -962,7 +958,10 @@ mod tests {
) )
.unwrap(); .unwrap();
let read_back = read_last_project(tmp.path()).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] #[test]
+30 -15
View File
@@ -17,8 +17,8 @@
use std::path::Path; use std::path::Path;
use rand::seq::IndexedRandom;
use rand::Rng; use rand::Rng;
use rand::seq::IndexedRandom;
const WORDLIST: &str = include_str!("wordlist.txt"); const WORDLIST: &str = include_str!("wordlist.txt");
const MAX_COLLISION_RETRIES: usize = 100; const MAX_COLLISION_RETRIES: usize = 100;
@@ -41,10 +41,9 @@ pub enum NamingError {
impl std::fmt::Display for NamingError { impl std::fmt::Display for NamingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::WordlistTooSmall(n) => f.write_str(&crate::t!( Self::WordlistTooSmall(n) => {
"project.naming.wordlist_too_small", f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,))
count = n, }
)),
Self::TooManyCollisions(n) => f.write_str(&crate::t!( Self::TooManyCollisions(n) => f.write_str(&crate::t!(
"project.naming.too_many_collisions", "project.naming.too_many_collisions",
attempts = n, attempts = n,
@@ -189,10 +188,9 @@ impl std::fmt::Display for UserNameError {
match self { match self {
Self::Empty => f.write_str(&crate::t!("project.user_name.empty")), Self::Empty => f.write_str(&crate::t!("project.user_name.empty")),
Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")), Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")),
Self::InvalidChar(c) => f.write_str(&crate::t!( Self::InvalidChar(c) => {
"project.user_name.invalid_char", f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,))
ch = c, }
)),
} }
} }
} }
@@ -209,14 +207,22 @@ mod tests {
#[test] #[test]
fn wordlist_has_enough_entries() { fn wordlist_has_enough_entries() {
let pool = words(); let pool = words();
assert!(pool.len() >= 100, "wordlist suspiciously small: {} entries", pool.len()); assert!(
pool.len() >= 100,
"wordlist suspiciously small: {} entries",
pool.len()
);
} }
#[test] #[test]
fn wordlist_has_no_duplicates() { fn wordlist_has_no_duplicates() {
let pool = words(); let pool = words();
let unique: std::collections::HashSet<_> = pool.iter().collect(); 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] #[test]
@@ -290,7 +296,7 @@ mod tests {
assert!(!is_temp_dirname("MyOrders")); assert!(!is_temp_dirname("MyOrders"));
assert!(!is_temp_dirname("term_planner")); assert!(!is_temp_dirname("term_planner"));
assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker 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] #[test]
@@ -301,9 +307,18 @@ mod tests {
assert!(validate_user_name("project.v2").is_ok()); assert!(validate_user_name("project.v2").is_ok());
assert_eq!(validate_user_name(""), Err(UserNameError::Empty)); assert_eq!(validate_user_name(""), Err(UserNameError::Empty));
assert_eq!(validate_user_name(".hidden"), Err(UserNameError::LeadingDot)); assert_eq!(
assert!(matches!(validate_user_name("a/b"), Err(UserNameError::InvalidChar('/')))); validate_user_name(".hidden"),
assert!(matches!(validate_user_name("a b"), Err(UserNameError::InvalidChar(' ')))); 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 { fn tempdir() -> tempfile::TempDir {
+8 -2
View File
@@ -129,7 +129,10 @@ mod tests {
#[test] #[test]
fn strips_date_prefix_from_temp_project_names() { 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] #[test]
@@ -205,6 +208,9 @@ mod tests {
#[test] #[test]
fn handles_mixed_separators_and_case() { 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::{ use crate::dsl::command::{
Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint, Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint,
}; };
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
use crate::dsl::walker::Severity; use crate::dsl::walker::Severity;
use crate::dsl::{AlterTableAction, ChangeColumnMode, ColumnSpec, Command};
use crate::event::AppEvent; use crate::event::AppEvent;
use crate::project::{ use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir, 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 // to it for `new` (creates a temp) and `load` (lists
// projects). We can't easily recover this from the // projects). We can't easily recover this from the
// Project alone, so we keep it ourselves. // Project alone, so we keep it ourselves.
let data_root = resolve_data_root(args.data_dir.as_deref()) let data_root = resolve_data_root(args.data_dir.as_deref()).context("resolve data root")?;
.context("resolve data root")?;
// Resolve the initial project path: --resume reads it from // Resolve the initial project path: --resume reads it from
// <data-root>/last_project; otherwise an explicit positional // <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 // terminal so the message lands directly in the user's
// shell. // shell.
let initial_path: Option<PathBuf> = if args.resume { let initial_path: Option<PathBuf> = if args.resume {
match read_last_project(&data_root) match read_last_project(&data_root).context("read last_project")? {
.context("read last_project")?
{
Some(p) if p.exists() => Some(p), Some(p) if p.exists() => Some(p),
Some(p) => { Some(p) => {
eprintln!( eprintln!(
"rdbms-playground: {}", "rdbms-playground: {}",
crate::t!( crate::t!("project.resume_recorded_missing", path = p.display(),),
"project.resume_recorded_missing",
path = p.display(),
),
); );
return Ok(()); return Ok(());
} }
@@ -488,19 +482,15 @@ async fn run_loop(
// Best-effort — a failure to record a failure must // Best-effort — a failure to record a failure must
// never escalate a user error into a fatal, so the // never escalate a user error into a fatal, so the
// result is logged and ignored. // result is logged and ignored.
if let Err(e) = crate::persistence::Persistence::new( if let Err(e) =
session.project().path().to_path_buf(), crate::persistence::Persistence::new(session.project().path().to_path_buf())
) .append_history_failure(&source, advanced)
.append_history_failure(&source, advanced)
{ {
tracing::warn!(error = %e, "failed to journal err record (ignored)"); tracing::warn!(error = %e, "failed to journal err record (ignored)");
} }
} }
Action::PrepareRebuild => { Action::PrepareRebuild => {
spawn_prepare_rebuild( spawn_prepare_rebuild(session.project().path().to_path_buf(), event_tx.clone());
session.project().path().to_path_buf(),
event_tx.clone(),
);
} }
Action::Rebuild { source } => { Action::Rebuild { source } => {
spawn_rebuild( spawn_rebuild(
@@ -671,8 +661,8 @@ async fn run_loop(
// mutually exclusive (one needs an unmodified temp, the // mutually exclusive (one needs an unmodified temp, the
// other anything else). // other anything else).
let project_at_quit = session.project.as_ref(); let project_at_quit = session.project.as_ref();
let cleanup_on_quit: Option<std::path::PathBuf> = project_at_quit let cleanup_on_quit: Option<std::path::PathBuf> =
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf())); 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 let resume_target_on_quit: Option<std::path::PathBuf> = project_at_quit
.filter(|p| !p.is_unmodified_temp()) .filter(|p| !p.is_unmodified_temp())
.map(|p| p.path().to_path_buf()); .map(|p| p.path().to_path_buf());
@@ -831,7 +821,10 @@ async fn perform_switch(
Some(p) Some(p)
} }
SwitchRequest::NewTemp => None, SwitchRequest::NewTemp => None,
SwitchRequest::Import { zip_path, as_target } => { SwitchRequest::Import {
zip_path,
as_target,
} => {
if !zip_path.exists() { if !zip_path.exists() {
return Err(crate::t!( return Err(crate::t!(
"project.import_zip_missing", "project.import_zip_missing",
@@ -840,8 +833,7 @@ async fn perform_switch(
} }
// Validate the zip up front so we don't drop the // Validate the zip up front so we don't drop the
// current project for an unimportable file. // current project for an unimportable file.
let inspection = crate::archive::inspect_zip(zip_path) let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
let resolved = resolve_import_destination( let resolved = resolve_import_destination(
as_target.as_deref(), as_target.as_deref(),
&inspection.top_folder, &inspection.top_folder,
@@ -856,16 +848,19 @@ async fn perform_switch(
// state matches the in-memory db). // state matches the in-memory db).
if let SwitchRequest::SaveAs { .. } = &req { if let SwitchRequest::SaveAs { .. } = &req {
let src = session.project().path().to_path_buf(); 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())?; copy_project(&src, dst).map_err(|e| e.to_string())?;
} }
// For Import: extract the zip into the resolved target. // For Import: extract the zip into the resolved target.
// We do this *before* dropping the current project so // We do this *before* dropping the current project so
// a failure here leaves the user where they were. // a failure here leaves the user where they were.
if let SwitchRequest::Import { zip_path, .. } = &req { if let SwitchRequest::Import { zip_path, .. } = &req {
let dst = resolved_target.as_ref().expect("Import has resolved target"); let dst = resolved_target
let inspection = crate::archive::inspect_zip(zip_path) .as_ref()
.map_err(|e| e.to_string())?; .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) crate::archive::extract_into(zip_path, dst, &inspection.top_folder)
.map_err(|e| e.to_string())?; .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 // we drop it: if it was an unmodified empty temp, we
// delete its directory after the switch so the data dir // delete its directory after the switch so the data dir
// doesn't accumulate empty scratch projects. // doesn't accumulate empty scratch projects.
let outgoing_cleanup_path: Option<std::path::PathBuf> = let outgoing_cleanup_path: Option<std::path::PathBuf> = session
session.project.as_ref().and_then(|p| { .project
p.is_unmodified_temp().then(|| p.path().to_path_buf()) .as_ref()
}); .and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
// Drop current project + database BEFORE opening the new // Drop current project + database BEFORE opening the new
// ones, releasing the old lock and stopping the old // ones, releasing the old lock and stopping the old
@@ -954,9 +949,7 @@ async fn perform_switch(
let new_database = let new_database =
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled) Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !db_existed if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await {
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
{
return Err(e.friendly_message()); return Err(e.friendly_message());
} }
@@ -982,9 +975,7 @@ async fn perform_switch(
// fresh empty temp (a `new` command), which must not be // fresh empty temp (a `new` command), which must not be
// recorded (see the gate in `run()`). Write failures are // recorded (see the gate in `run()`). Write failures are
// non-fatal. // non-fatal.
if new_worth_recording if new_worth_recording && let Err(e) = write_last_project(&session.data_root, &new_path) {
&& let Err(e) = write_last_project(&session.data_root, &new_path)
{
tracing::warn!(error = %e, "could not update last_project after switch"); tracing::warn!(error = %e, "could not update last_project after switch");
} }
@@ -1045,8 +1036,8 @@ fn spawn_export(
event_tx: mpsc::Sender<AppEvent>, event_tx: mpsc::Sender<AppEvent>,
) { ) {
// `export` app command: journalled simple (ADR-0052). // `export` app command: journalled simple (ADR-0052).
let _ = crate::persistence::Persistence::new(project_path.clone()) let _ =
.append_history(&source, false); crate::persistence::Persistence::new(project_path.clone()).append_history(&source, false);
tokio::spawn(async move { tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || { let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref()) do_export(&project_path, &project_name, &data_root, target.as_deref())
@@ -1081,9 +1072,8 @@ fn do_export(
} }
None => { None => {
std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?; std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
let (filename, _) = let (filename, _) = crate::archive::next_export_sequence(data_root, project_name)
crate::archive::next_export_sequence(data_root, project_name) .map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
data_root.join(filename) 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 /// no completion. Called wherever `TablesRefreshed` is sent
/// today; the schema cache lives on the App and feeds Tab /// today; the schema cache lives on the App and feeds Tab
/// completion for identifier slots. /// completion for identifier slots.
async fn refresh_schema_cache( async fn refresh_schema_cache(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
database: &Database,
event_tx: &mpsc::Sender<AppEvent>,
) {
let cache = build_schema_cache(database).await; let cache = build_schema_cache(database).await;
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await; let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
// ADR-0046 DB2: full relationship records for the sidebar panel. // 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 /// summary that the confirmation modal shows. Runs off the
/// event loop so the brief I/O doesn't stall input handling /// event loop so the brief I/O doesn't stall input handling
/// even on slow filesystems. /// even on slow filesystems.
fn spawn_prepare_rebuild( fn spawn_prepare_rebuild(project_path: std::path::PathBuf, event_tx: mpsc::Sender<AppEvent>) {
project_path: std::path::PathBuf,
event_tx: mpsc::Sender<AppEvent>,
) {
tokio::spawn(async move { tokio::spawn(async move {
let summary = match summarize_project(&project_path) { let summary = match summarize_project(&project_path) {
Ok(s) => s, Ok(s) => s,
@@ -1317,9 +1301,7 @@ fn spawn_rebuild(
} }
let summary = summarize_project(&project_path) let summary = summarize_project(&project_path)
.unwrap_or_else(|_| "rebuild complete".to_string()); .unwrap_or_else(|_| "rebuild complete".to_string());
let _ = event_tx let _ = event_tx.send(AppEvent::RebuildSucceeded { summary }).await;
.send(AppEvent::RebuildSucceeded { summary })
.await;
// Refresh the table list so the items panel // Refresh the table list so the items panel
// reflects whatever the rebuild produced. // reflects whatever the rebuild produced.
if let Ok(tables) = database.list_tables().await { if let Ok(tables) = database.list_tables().await {
@@ -1462,12 +1444,8 @@ fn spawn_dsl_dispatch(
} }
let event = match outcome { let event = match outcome {
Ok(CommandOutcome::Schema(description)) => { Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo( let schema_echo =
&command, build_schema_echo(&command, submission_mode, description.as_ref(), &lookups);
submission_mode,
description.as_ref(),
&lookups,
);
AppEvent::DslSucceeded { AppEvent::DslSucceeded {
command: command.clone(), command: command.clone(),
description, description,
@@ -1484,12 +1462,10 @@ fn spawn_dsl_dispatch(
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped { Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
command: command.clone(), command: command.clone(),
}, },
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => { Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => AppEvent::DslCreateIndexSkipped {
AppEvent::DslCreateIndexSkipped { command: command.clone(),
command: command.clone(), name,
name, },
}
}
Ok(CommandOutcome::Query(data)) => { Ok(CommandOutcome::Query(data)) => {
// ADR-0038: `show data` is the only DSL-form query that // ADR-0038: `show data` is the only DSL-form query that
// echoes; its limited form orders by the table's primary // echoes; its limited form orders by the table's primary
@@ -1507,12 +1483,10 @@ fn spawn_dsl_dispatch(
command: command.clone(), command: command.clone(),
lines, lines,
}, },
Ok(CommandOutcome::ShowRelationship(data)) => { Ok(CommandOutcome::ShowRelationship(data)) => AppEvent::DslShowRelationshipSucceeded {
AppEvent::DslShowRelationshipSucceeded { command: command.clone(),
command: command.clone(), data: data.map(|b| *b),
data: data.map(|b| *b), },
}
}
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded { Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
command: command.clone(), command: command.clone(),
plan, plan,
@@ -1568,11 +1542,8 @@ fn spawn_dsl_dispatch(
// the covering indexes the rebuild removed — Bucket B // the covering indexes the rebuild removed — Bucket B
// category 2, ADR-0038 §7 Slice 2b). Non-cascade falls // category 2, ADR-0038 §7 Slice 2b). Non-cascade falls
// through to the pre-execution `echo` from `echo_for`. // through to the pre-execution `echo` from `echo_for`.
let cascade_echo = build_drop_column_cascade_echo( let cascade_echo =
&command, build_drop_column_cascade_echo(&command, submission_mode, &result);
submission_mode,
&result,
);
AppEvent::DslDropColumnSucceeded { AppEvent::DslDropColumnSucceeded {
command: command.clone(), command: command.clone(),
result, result,
@@ -1931,12 +1902,14 @@ fn build_schema_echo(
)]) )])
} }
} }
Command::DropRelationship { .. } => lookups Command::DropRelationship { .. } => {
.drop_relationship lookups
.as_ref() .drop_relationship
.map(|(name, child_table)| { .as_ref()
vec![crate::echo::render_drop_relationship(name, child_table)] .map(|(name, child_table)| {
}), vec![crate::echo::render_drop_relationship(name, child_table)]
})
}
// `create m:n relationship` (ADR-0045): the resolved junction // `create m:n relationship` (ADR-0045): the resolved junction
// columns/FKs only exist on the post-exec description, so the // columns/FKs only exist on the post-exec description, so the
// teaching echo is rendered from it (not `command_to_sql`). // teaching echo is rendered from it (not `command_to_sql`).
@@ -1946,14 +1919,29 @@ fn build_schema_echo(
.iter() .iter()
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty))) .filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
.collect(); .collect();
let primary_key: Vec<String> = let primary_key: Vec<String> = desc
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect(); .columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.clone())
.collect();
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
.outbound_relationships .outbound_relationships
.iter() .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(); .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 // Everything else (Bucket A pure-Command, plus the no-echo Bucket C
// variants like `Sql*` / `ShowTable`) routes through the existing // variants like `Sql*` / `ShowTable`) routes through the existing
@@ -2103,10 +2091,7 @@ async fn enrich_unique_violation(
facts facts
} }
fn enrich_not_null_violation( fn enrich_not_null_violation(command: &Command, message: &str) -> crate::friendly::FailureContext {
command: &Command,
message: &str,
) -> crate::friendly::FailureContext {
let mut facts = crate::friendly::FailureContext::default(); let mut facts = crate::friendly::FailureContext::default();
let Some((table, column)) = parse_qualified_target(message) else { let Some((table, column)) = parse_qualified_target(message) else {
return facts; return facts;
@@ -2133,9 +2118,7 @@ async fn enrich_fk_violation(
// schema-aware lookup so natural-order multi-value // schema-aware lookup so natural-order multi-value
// INSERT (which `user_value_for_column` alone can't // INSERT (which `user_value_for_column` alone can't
// resolve) gets handled too. // resolve) gets handled too.
let Ok((outbound, _)) = let Ok((outbound, _)) = database.read_relationships(table.clone()).await else {
database.read_relationships(table.clone()).await
else {
return facts; return facts;
}; };
facts.table = Some(table.clone()); facts.table = Some(table.clone());
@@ -2173,8 +2156,7 @@ async fn enrich_fk_violation(
// children reference). Check inbound as a fallback. // children reference). Check inbound as a fallback.
if facts.parent_table.is_none() if facts.parent_table.is_none()
&& matches!(command, Command::Update { .. }) && matches!(command, Command::Update { .. })
&& let Ok((_, inbound)) = && let Ok((_, inbound)) = database.read_relationships(table.clone()).await
database.read_relationships(table.clone()).await
&& let Some(rel) = inbound.first() && let Some(rel) = inbound.first()
{ {
facts.child_table = Some(rel.other_table.clone()); 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 // Parent-side: inbound FK lookup. Surface a child
// table that still references the row(s) being // table that still references the row(s) being
// deleted. // deleted.
let Ok((_, inbound)) = let Ok((_, inbound)) = database.read_relationships(table.clone()).await else {
database.read_relationships(table.clone()).await
else {
return facts; return facts;
}; };
facts.table = Some(table.clone()); facts.table = Some(table.clone());
@@ -2271,10 +2251,7 @@ async fn user_value_for_column_with_schema(
.. ..
} = command } = command
{ {
let desc = database let desc = database.describe_table(table.to_string()).await.ok()?;
.describe_table(table.to_string())
.await
.ok()?;
// Build the natural-order column list the same way // Build the natural-order column list the same way
// `do_insert` does: filter out serial / shortid columns // `do_insert` does: filter out serial / shortid columns
// because the engine auto-fills them and the user's // because the engine auto-fills them and the user's
@@ -2285,8 +2262,7 @@ async fn user_value_for_column_with_schema(
.filter(|c| { .filter(|c| {
!matches!( !matches!(
c.user_type, c.user_type,
Some(crate::dsl::Type::Serial) Some(crate::dsl::Type::Serial) | Some(crate::dsl::Type::ShortId)
| Some(crate::dsl::Type::ShortId)
) )
}) })
.map(|c| c.name.as_str()) .map(|c| c.name.as_str())
@@ -2310,10 +2286,7 @@ async fn user_value_for_column_with_schema(
&& listed_columns.is_empty() && listed_columns.is_empty()
&& literal_rows.len() == 1 && literal_rows.len() == 1
{ {
let desc = database let desc = database.describe_table(table.to_string()).await.ok()?;
.describe_table(table.to_string())
.await
.ok()?;
let idx = desc.columns.iter().position(|c| c.name == column)?; let idx = desc.columns.iter().position(|c| c.name == column)?;
return literal_rows[0].get(idx).cloned().flatten(); 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 /// Render a `DataResult` as a `DiagnosticTable` for the
/// friendly-error layer's bordered renderer (ADR-0019 §7, /// friendly-error layer's bordered renderer (ADR-0019 §7,
/// reusing ADR-0017 §7's renderer). /// reusing ADR-0017 §7's renderer).
fn diagnostic_from_data_result( fn diagnostic_from_data_result(data: &DataResult) -> crate::friendly::DiagnosticTable {
data: &DataResult, use crate::output_render::{Alignment, numeric_alignment_for};
) -> crate::friendly::DiagnosticTable {
use crate::output_render::{numeric_alignment_for, Alignment};
let alignments: Vec<Alignment> = data let alignments: Vec<Alignment> = data
.column_types .column_types
.iter() .iter()
.map(|t| { .map(|t| t.map_or(Alignment::Left, numeric_alignment_for))
t.map_or(Alignment::Left, numeric_alignment_for)
})
.collect(); .collect();
let rows: Vec<Vec<String>> = data let rows: Vec<Vec<String>> = data
.rows .rows
@@ -2543,9 +2512,7 @@ pub async fn run_replay(
// command, which was skipped above) — report it with the line // command, which was skipped above) — report it with the line
// number and stop. // number and stop.
let schema = build_schema_cache(database).await; let schema = build_schema_cache(database).await;
let command = match crate::dsl::parser::parse_command_with_schema( let command = match crate::dsl::parser::parse_command_with_schema(&command_text, &schema) {
&command_text, &schema,
) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
events.push(AppEvent::ReplayFailed { events.push(AppEvent::ReplayFailed {
@@ -2566,8 +2533,7 @@ pub async fn run_replay(
// Retain a clone for failure enrichment (the command is moved into // Retain a clone for failure enrichment (the command is moved into
// dispatch). ADR-0035 Amendment 1, F2 follow-up. // dispatch). ADR-0035 Amendment 1, F2 follow-up.
let command_for_ctx = command.clone(); let command_for_ctx = command.clone();
let outcome = let outcome = execute_command_typed(database, command, command_text.clone()).await;
execute_command_typed(database, command, command_text.clone()).await;
match outcome { match outcome {
Ok(_) => { Ok(_) => {
// ADR-0052: journal the replayed line at the dispatch // 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) .drop_constraint(table, column, ConstraintKind::NotNull, src)
.await .await
.map(|d| CommandOutcome::Schema(Some(d))), .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) .set_column_default(table, column, default_sql, src)
.await .await
.map(|d| CommandOutcome::Schema(Some(d))), .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). // A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
// The grammar walker has already validated `sql` is in // The grammar walker has already validated `sql` is in
// the supported subset; the worker runs it as text. // the supported subset; the worker runs it as text.
Command::Select { sql } => database Command::Select { sql } => database.run_select(sql).await.map(CommandOutcome::Query),
.run_select(sql)
.await
.map(CommandOutcome::Query),
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as- // A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
// text: the worker runs the validated `sql` and re-persists // text: the worker runs the validated `sql` and re-persists
// the parsed `target_table`'s CSV. Reuses the DSL insert // the parsed `target_table`'s CSV. Reuses the DSL insert
@@ -3112,12 +3078,9 @@ fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
Ok(terminal) Ok(terminal)
} }
fn teardown_terminal( fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
disable_raw_mode().context("disable raw mode")?; disable_raw_mode().context("disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen) execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alternate screen")?;
.context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?; terminal.show_cursor().context("show cursor")?;
Ok(()) Ok(())
} }
@@ -3257,7 +3220,9 @@ mod tests {
// Limited → ORDER BY the resolved primary key. // Limited → ORDER BY the resolved primary key.
assert_eq!( assert_eq!(
super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await, 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. // Simple mode → silent, gated before any lookup.
assert_eq!( assert_eq!(
@@ -3288,10 +3253,10 @@ mod tests {
async fn bucket_b_resolved_name_echoes_against_real_worker() { async fn bucket_b_resolved_name_echoes_against_real_worker() {
use crate::app::EffectiveMode; use crate::app::EffectiveMode;
use crate::db::Database; use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction; use crate::dsl::ReferentialAction;
use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector}; use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector};
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::Command;
let db = Database::open(":memory:").expect("open in-memory"); let db = Database::open(":memory:").expect("open in-memory");
db.create_table( db.create_table(
@@ -3319,7 +3284,12 @@ mod tests {
// --- add index (auto-named) ---------------------------------- // --- add index (auto-named) ----------------------------------
let desc_after_add_index = db 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 .await
.expect("add index"); .expect("add index");
let add_idx_cmd = Command::AddIndex { let add_idx_cmd = Command::AddIndex {
@@ -3439,7 +3409,10 @@ mod tests {
.await; .await;
assert_eq!( assert_eq!(
endpoints_lookups.drop_relationship, 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", "endpoints selector resolves name via child describe",
); );
@@ -3454,7 +3427,10 @@ mod tests {
.await; .await;
assert_eq!( assert_eq!(
named_lookups.drop_relationship, 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", "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() { async fn bucket_b_multi_statement_echoes_against_real_worker() {
use crate::app::EffectiveMode; use crate::app::EffectiveMode;
use crate::db::Database; use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction; use crate::dsl::ReferentialAction;
use crate::dsl::command::ColumnSpec; use crate::dsl::command::ColumnSpec;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::Command;
// --- drop column --cascade ----------------------------------- // --- drop column --cascade -----------------------------------
let db = Database::open(":memory:").expect("open in-memory"); let db = Database::open(":memory:").expect("open in-memory");
@@ -3505,9 +3481,14 @@ mod tests {
) )
.await .await
.expect("create Customers"); .expect("create Customers");
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None) db.add_index(
.await None,
.expect("index Email"); "Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("index Email");
let drop_cmd = Command::DropColumn { let drop_cmd = Command::DropColumn {
table: "Customers".to_string(), table: "Customers".to_string(),
@@ -3531,12 +3512,8 @@ mod tests {
); );
// Simple mode → silent. // Simple mode → silent.
assert!( assert!(
super::build_drop_column_cascade_echo( super::build_drop_column_cascade_echo(&drop_cmd, EffectiveMode::Simple, &drop_result,)
&drop_cmd, .is_none(),
EffectiveMode::Simple,
&drop_result,
)
.is_none(),
); );
// --- add relationship --create-fk (column newly created) ---- // --- add relationship --create-fk (column newly created) ----
@@ -3673,11 +3650,11 @@ mod tests {
// switch (an unmodified temp would be cleaned up, taking its // switch (an unmodified temp would be cleaned up, taking its
// project.yaml with it). Without the unload persist the // project.yaml with it). Without the unload persist the
// outgoing skeleton carries no `mode:` → `None`. // 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::db::Database;
use crate::mode::Mode; use crate::mode::Mode;
use crate::persistence::Persistence; use crate::persistence::Persistence;
use crate::project::{projects_dir, Project}; use crate::project::{Project, projects_dir};
use tokio::sync::mpsc; use tokio::sync::mpsc;
let data_root = tempfile::tempdir().unwrap(); let data_root = tempfile::tempdir().unwrap();
@@ -3686,8 +3663,7 @@ mod tests {
let outgoing_path = projects.join("Outgoing"); let outgoing_path = projects.join("Outgoing");
let outgoing = Project::create_named(&outgoing_path).unwrap(); let outgoing = Project::create_named(&outgoing_path).unwrap();
let db_path = outgoing.db_path(); let db_path = outgoing.db_path();
let persistence = let persistence = Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
let database = let database =
Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap(); Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap();
let mut session = Session { 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; return None;
} }
let values = extract_quoted_list(&check[paren_open..])?; 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 { const fn is_ident_byte(b: u8) -> bool {
@@ -45,8 +49,8 @@ fn find_in_paren(check: &str) -> Option<(usize, usize)> {
i += 1; i += 1;
continue; continue;
} }
let is_in = (b == b'i' || b == b'I') let is_in =
&& bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N'); (b == b'i' || b == b'I') && bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
if is_in { if is_in {
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]); let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
let after = i + 2; 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::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()), Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()), Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => { Generator::YearRecent => Value::Number(
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string()) rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR)
} .to_string(),
),
Generator::YearBirth => Value::Number( Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE)) rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(), .to_string(),
), ),
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))), Generator::DateRecent => {
Generator::DateAdult => { Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS)))
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_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::DateTimeRecent => Value::Text(random_recent_datetime(rng)),
Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1), Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1),
Generator::PickFrom(values) if !values.is_empty() => { Generator::PickFrom(values) if !values.is_empty() => {
@@ -232,8 +237,7 @@ fn random_datetime_between(
} else { } else {
rng.random_range(hi_s..=lo_s) rng.random_range(hi_s..=lo_s)
}; };
let dt = chrono::DateTime::from_timestamp(secs, 0) let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc());
.map_or(lo, |d| d.naive_utc());
dt.format("%Y-%m-%dT%H:%M:%S").to_string() 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) — // — the hand-rolled `product` generator (D9) —
const PRODUCT_ADJECTIVES: &[&str] = &[ const PRODUCT_ADJECTIVES: &[&str] = &[
"Sleek", "Rustic", "Ergonomic", "Handcrafted", "Refined", "Modern", "Sleek",
"Vintage", "Compact", "Premium", "Lightweight", "Durable", "Elegant", "Rustic",
"Sturdy", "Smooth", "Gorgeous", "Intelligent", "Practical", "Awesome", "Ergonomic",
"Incredible", "Recycled", "Handcrafted",
"Refined",
"Modern",
"Vintage",
"Compact",
"Premium",
"Lightweight",
"Durable",
"Elegant",
"Sturdy",
"Smooth",
"Gorgeous",
"Intelligent",
"Practical",
"Awesome",
"Incredible",
"Recycled",
]; ];
const PRODUCT_MATERIALS: &[&str] = &[ const PRODUCT_MATERIALS: &[&str] = &[
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic",
"Plastic", "Ceramic", "Glass", "Concrete", "Rubber", "Bronze", "Marble", "Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold",
"Linen", "Silk", "Aluminum", "Wool", "Gold", "Carbon", "Carbon",
]; ];
const PRODUCT_NOUNS: &[&str] = &[ const PRODUCT_NOUNS: &[&str] = &[
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch",
"Shoes", "Jacket", "Watch", "Wallet", "Bench", "Hat", "Gloves", "Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
"Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
]; ];
fn product_name(rng: &mut SeedRng) -> String { fn product_name(rng: &mut SeedRng) -> String {
@@ -396,7 +415,9 @@ mod tests {
] { ] {
let v = gen_once(&generator, Type::Text, 3); let v = gen_once(&generator, Type::Text, 3);
match v { 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:?}"), other => panic!("{generator:?} produced non-text {other:?}"),
} }
} }
@@ -405,18 +426,25 @@ mod tests {
#[test] #[test]
fn email_looks_like_an_email() { fn email_looks_like_an_email() {
let v = gen_once(&Generator::Email, Type::Text, 11); 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}"); assert!(s.contains('@'), "email should contain @: {s}");
} }
#[test] #[test]
fn product_name_is_three_capitalised_words() { fn product_name_is_three_capitalised_words() {
let v = gen_once(&Generator::ProductName, Type::Text, 99); 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(); let words: Vec<&str> = s.split(' ').collect();
assert_eq!(words.len(), 3, "product name should be 3 words: {s}"); assert_eq!(words.len(), 3, "product name should be 3 words: {s}");
for w in words { 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(); let latest = reference_date();
for _ in 0..200 { for _ in 0..200 {
let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng); 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"); 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(); .unwrap();
for _ in 0..200 { for _ in 0..200 {
let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng); 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"); let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "dob {d} outside adult window"); assert!(d >= earliest && d <= latest, "dob {d} outside adult window");
} }
@@ -455,7 +490,9 @@ mod tests {
#[test] #[test]
fn datetime_is_iso_shaped() { fn datetime_is_iso_shaped() {
let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5); 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}"); assert!(s.contains('T'), "datetime needs a T separator: {s}");
// Parses as a naive datetime. // Parses as a naive datetime.
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S") 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 { let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else {
panic!("not a number") 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 { let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else {
panic!("not a number") 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] #[test]
@@ -494,7 +537,10 @@ mod tests {
let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else { let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else {
panic!("not text") 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 generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]);
let mut rng = make_rng(Some(6)); let mut rng = make_rng(Some(6));
let v = generate_value(&generator, Type::Int, &mut rng); 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] #[test]
@@ -517,7 +566,10 @@ mod tests {
panic!("YearRecent must be a Number") panic!("YearRecent must be a Number")
}; };
let n: i32 = s.parse().unwrap(); 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 { for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng) let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
@@ -525,7 +577,10 @@ mod tests {
panic!("YearBirth must be a Number") panic!("YearBirth must be a Number")
}; };
let n: i32 = s.parse().unwrap(); 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] #[test]
fn int_range_stays_within_inclusive_bounds() { 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)); let mut rng = make_rng(Some(5));
for _ in 0..200 { for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else { let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
@@ -556,7 +614,10 @@ mod tests {
#[test] #[test]
fn real_range_stays_within_bounds_and_has_cents() { 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)); let mut rng = make_rng(Some(5));
for _ in 0..200 { for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else { let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else {
@@ -588,13 +649,19 @@ mod tests {
#[test] #[test]
fn reversed_bounds_are_tolerated() { 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 mut rng = make_rng(Some(1));
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else { let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
panic!("number") panic!("number")
}; };
let n: i64 = s.parse().unwrap(); 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] #[test]
@@ -603,7 +670,10 @@ mod tests {
assert!(range_bounds_reason(Type::Int, "1", "10").is_none()); 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::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::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. // Non-numeric bound on a numeric column.
assert!(range_bounds_reason(Type::Int, "abc", "10").is_some()); assert!(range_bounds_reason(Type::Int, "abc", "10").is_some());
// A range on a text column is meaningless. // A range on a text column is meaningless.
@@ -623,14 +693,29 @@ mod tests {
#[test] #[test]
fn generic_fallback_matches_each_type() { fn generic_fallback_matches_each_type() {
let mut rng = make_rng(Some(0)); let mut rng = make_rng(Some(0));
assert!(matches!(generate_value(&Generator::Generic, Type::Text, &mut rng), Value::Text(_))); assert!(matches!(
assert!(matches!(generate_value(&Generator::Generic, Type::Int, &mut rng), Value::Number(_))); generate_value(&Generator::Generic, Type::Text, &mut rng),
assert!(matches!(generate_value(&Generator::Generic, Type::Bool, &mut rng), Value::Bool(_))); Value::Text(_)
assert!(matches!(generate_value(&Generator::Generic, Type::Blob, &mut rng), Value::Null)); ));
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. // shortid fallback is a valid base58 id.
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else { let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
panic!("shortid not text") 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 // `rating` / `stars` were never here. `status` stays — it is
// deliberately left to the advisory (no built-in set). // deliberately left to the advisory (no built-in set).
const ENUM_TOKENS: &[&str] = &[ const ENUM_TOKENS: &[&str] = &[
"role", "status", "state", "type", "kind", "category", "level", "role", "status", "state", "type", "kind", "category", "level", "tier", "stage", "gender",
"tier", "stage", "gender",
]; ];
let toks = tokens(name); let toks = tokens(name);
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str())) 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")) { if text && (has_any(toks, &["fname", "firstname"]) || has_seq(toks, "first", "name")) {
return Some(Generator::FirstName); return Some(Generator::FirstName);
} }
if text if text && (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name")) {
&& (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name"))
{
return Some(Generator::LastName); return Some(Generator::LastName);
} }
if text && (has_any(toks, &["username", "login", "handle"]) || has_seq(toks, "user", "name")) { 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. // `province` / explicit `state_name`/`state_abbr` → a real state name.
// Bare `state` is left to enum-ish (it usually means status), so we // Bare `state` is left to enum-ish (it usually means status), so we
// require `province` or a `state` token paired with name/abbr. // 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); return Some(Generator::StateName);
} }
if text && has_any(toks, &["street", "address", "addr"]) { 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 — // — 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); return Some(Generator::Company);
} }
if text && has_any(toks, &["job", "position", "profession", "occupation"]) { 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 — // — 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); return Some(Generator::Sentence);
} }
if text && has_any(toks, &["url", "website", "homepage", "link"]) { 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 — // — 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); return Some(Generator::CurrencyAmount);
} }
if numeric && has_token(toks, "age") { 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 { fn name_by_table_context(table: &str) -> Generator {
let toks = tokens(table); let toks = tokens(table);
const PRODUCTY: &[&str] = &[ const PRODUCTY: &[&str] = &[
"product", "products", "item", "items", "good", "goods", "product",
"merchandise", "catalog", "catalogue", "inventory", "sku", "skus", "products",
"item",
"items",
"good",
"goods",
"merchandise",
"catalog",
"catalogue",
"inventory",
"sku",
"skus",
]; ];
const COMPANYISH: &[&str] = &[ const COMPANYISH: &[&str] = &[
"company", "companies", "vendor", "vendors", "supplier", "company",
"suppliers", "manufacturer", "manufacturers", "brand", "brands", "companies",
"organization", "organisation", "vendor",
"vendors",
"supplier",
"suppliers",
"manufacturer",
"manufacturers",
"brand",
"brands",
"organization",
"organisation",
]; ];
const PERSONISH: &[&str] = &[ const PERSONISH: &[&str] = &[
"user", "users", "customer", "customers", "person", "people", "user",
"employee", "employees", "member", "members", "contact", "users",
"contacts", "author", "authors", "student", "students", "customer",
"customers",
"person",
"people",
"employee",
"employees",
"member",
"members",
"contact",
"contacts",
"author",
"authors",
"student",
"students",
]; ];
if has_any(&toks, PRODUCTY) { if has_any(&toks, PRODUCTY) {
Generator::ProductName Generator::ProductName
@@ -264,9 +322,8 @@ fn name_by_table_context(table: &str) -> Generator {
/// before this guard; this catches structural names. /// before this guard; this catches structural names.
fn is_name_false_positive(toks: &[String]) -> bool { fn is_name_false_positive(toks: &[String]) -> bool {
const NON_PERSON: &[&str] = &[ const NON_PERSON: &[&str] = &[
"file", "table", "host", "domain", "field", "class", "tag", "file", "table", "host", "domain", "field", "class", "tag", "event", "path", "col",
"event", "path", "col", "column", "db", "schema", "index", "key", "column", "db", "schema", "index", "key", "page", "node", "type",
"page", "node", "type",
]; ];
has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"]) has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"])
} }
@@ -357,9 +414,18 @@ mod tests {
#[test] #[test]
fn person_name_fields_map_to_name_generators() { fn person_name_fields_map_to_name_generators() {
assert_eq!(choose("users", "first_name", Type::Text), Generator::FirstName); assert_eq!(
assert_eq!(choose("users", "firstName", Type::Text), Generator::FirstName); choose("users", "first_name", Type::Text),
assert_eq!(choose("users", "last_name", Type::Text), Generator::LastName); 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); 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", "email", Type::Text), Generator::Email);
assert_eq!(choose("users", "work_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", "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("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] #[test]
@@ -386,7 +458,10 @@ mod tests {
#[test] #[test]
fn bare_name_uses_table_context() { fn bare_name_uses_table_context() {
// D11 — the same column name resolves differently by table. // 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("items", "title", Type::Text), Generator::ProductName);
assert_eq!(choose("users", "name", Type::Text), Generator::FullName); assert_eq!(choose("users", "name", Type::Text), Generator::FullName);
assert_eq!(choose("customers", "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() { fn name_false_positives_do_not_become_person_names() {
// These must NOT resolve to a person/product name. // These must NOT resolve to a person/product name.
assert_ne!(choose("files", "filename", Type::Text), Generator::FullName); 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. // They fall through to a generic / non-person generator.
assert_eq!(choose("files", "filename", Type::Text), Generator::Generic); assert_eq!(choose("files", "filename", Type::Text), Generator::Generic);
} }
@@ -408,7 +486,10 @@ mod tests {
fn numeric_name_heuristics_are_type_gated() { fn numeric_name_heuristics_are_type_gated() {
// `price` on a numeric column → currency; on text → falls through. // `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::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("p", "price", Type::Text), Generator::Generic);
assert_eq!(choose("u", "age", Type::Int), Generator::Age); assert_eq!(choose("u", "age", Type::Int), Generator::Age);
assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt); assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt);
@@ -425,8 +506,14 @@ mod tests {
fn temporal_fields_are_bounded_and_type_gated() { fn temporal_fields_are_bounded_and_type_gated() {
assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult); assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult);
assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent); assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent);
assert_eq!(choose("o", "created_at", Type::DateTime), Generator::DateTimeRecent); assert_eq!(
assert_eq!(choose("o", "timestamp", Type::DateTime), Generator::DateTimeRecent); choose("o", "created_at", Type::DateTime),
Generator::DateTimeRecent
);
assert_eq!(
choose("o", "timestamp", Type::DateTime),
Generator::DateTimeRecent
);
// Wrong type → not a date generator. // Wrong type → not a date generator.
assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic); assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic);
} }
@@ -440,17 +527,32 @@ mod tests {
#[test] #[test]
fn identifier_family_is_unique_sequential() { fn identifier_family_is_unique_sequential() {
assert_eq!(choose("t", "code", Type::Text), Generator::IdentitySequential); assert_eq!(
assert_eq!(choose("t", "sku", Type::Text), Generator::IdentitySequential); choose("t", "code", Type::Text),
assert_eq!(choose("t", "order_number", Type::Int), Generator::IdentitySequential); Generator::IdentitySequential
assert_eq!(choose("t", "external_id", Type::Int), 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] #[test]
fn foreign_key_columns_defer_to_executor() { fn foreign_key_columns_defer_to_executor() {
let mut spec = ColumnSpec::plain("user_id", Type::Int); let mut spec = ColumnSpec::plain("user_id", Type::Int);
spec.is_foreign_key = true; spec.is_foreign_key = true;
assert_eq!(choose_generator("orders", &spec), Generator::ForeignKeySample); assert_eq!(
choose_generator("orders", &spec),
Generator::ForeignKeySample
);
} }
#[test] #[test]
@@ -481,13 +583,28 @@ mod tests {
fn year_like_int_columns_map_to_bounded_years() { fn year_like_int_columns_map_to_bounded_years() {
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob` // Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
// years pick the birth window; the rest a recent window. // years pick the birth window; the rest a recent window.
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth); assert_eq!(
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth); 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("u", "year_born", Type::Int), Generator::YearBirth);
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent); assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent); assert_eq!(
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent); choose("films", "release_year", Type::Int),
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent); 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. // Type-gated: a text `year` is not a bounded-year int.
assert_eq!(choose("books", "year", Type::Text), Generator::Generic); assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
// `year_count` is a count, not a year — the quantity rule wins. // `year_count` is a count, not a year — the quantity rule wins.
@@ -507,7 +624,12 @@ mod tests {
); );
assert_eq!( assert_eq!(
choose("bugs", "severity", Type::Text), 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!( assert_eq!(
choose("bugs", "severity", Type::Int), choose("bugs", "severity", Type::Int),
@@ -515,11 +637,23 @@ mod tests {
); );
assert_eq!( assert_eq!(
choose("reviews", "rating", Type::Int), 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!( assert_eq!(
choose("reviews", "stars", Type::Int), 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] #[test]
fn unmatched_columns_use_type_based_fallback() { 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] #[test]
+5 -2
View File
@@ -32,7 +32,7 @@ mod vocabulary;
pub use check::parse_in_check_values; pub use check::parse_in_check_values;
pub use generators::{generate_value, range_bounds_reason}; pub use generators::{generate_value, range_bounds_reason};
pub use heuristics::{choose_generator, is_enum_ish}; 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::rngs::StdRng;
use rand::{RngExt, SeedableRng}; use rand::{RngExt, SeedableRng};
@@ -183,7 +183,10 @@ pub enum Generator {
/// does not parse for the column type is a friendly error), so /// does not parse for the column type is a friendly error), so
/// [`generate_value`] only ever sees parseable bounds; a defensive /// [`generate_value`] only ever sees parseable bounds; a defensive
/// parse failure falls back to type-based generation. /// 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. /// Type-based fallback (D8) when no name heuristic matches.
Generic, Generic,
} }
+38 -29
View File
@@ -104,15 +104,15 @@ impl Theme {
// remains restful; literals and flags get warm // remains restful; literals and flags get warm
// accent tones; keyword takes a cool accent tone // accent tones; keyword takes a cool accent tone
// distinct from the mode-banner blue. // 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_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_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_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error 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_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 — // Light-theme token palette: same intent as dark —
// identifier/punct close to fg/muted; warm tones for // identifier/punct close to fg/muted; warm tones for
// literals + flags; cool accent for keyword. // 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_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_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_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error 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_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), ("tok_function", t.tok_function),
("warning", t.warning), ("warning", t.warning),
] { ] {
assert_ne!( assert_ne!(c, t.bg, "{name} must contrast against bg in dark theme",);
c, t.bg,
"{name} must contrast against bg in dark theme",
);
} }
} }
@@ -212,24 +209,36 @@ mod tests {
("tok_function", t.tok_function), ("tok_function", t.tok_function),
("warning", t.warning), ("warning", t.warning),
] { ] {
assert_ne!( assert_ne!(c, t.bg, "{name} must contrast against bg in light theme",);
c, t.bg,
"{name} must contrast against bg in light theme",
);
} }
} }
#[test] #[test]
fn highlight_class_color_maps_each_variant() { fn highlight_class_color_maps_each_variant() {
let t = Theme::dark(); let t = Theme::dark();
assert_eq!(t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword); assert_eq!(
assert_eq!(t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier); 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::Type), t.tok_type);
assert_eq!(t.highlight_class_color(HighlightClass::Number), t.tok_number); assert_eq!(
assert_eq!(t.highlight_class_color(HighlightClass::String), t.tok_string); 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::Punct), t.tok_punct);
assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag); 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); 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 { const fn is_in_matrix(src: Type, target: Type) -> bool {
use Type::{ use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
matches!( matches!(
(src, target), (src, target),
// Always-clean transformers // Always-clean transformers
@@ -130,9 +128,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
if matches!(value, Value::Null) { if matches!(value, Value::Null) {
return CellOutcome::Clean(Value::Null); return CellOutcome::Clean(Value::Null);
} }
use Type::{ use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
match (src, target) { match (src, target) {
// ---- Always-clean: int / serial source ---- // ---- Always-clean: int / serial source ----
(Int | Serial, Real) => match value { (Int | Serial, Real) => match value {
@@ -179,9 +175,11 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
(Bool, Text) => match value { (Bool, Text) => match value {
// "true" / "false" matches the DSL boolean grammar // "true" / "false" matches the DSL boolean grammar
// (ADR-0014 §5), not raw 0/1 stringification. // (ADR-0014 §5), not raw 0/1 stringification.
Value::Integer(i) => CellOutcome::Clean(Value::Text( Value::Integer(i) => CellOutcome::Clean(Value::Text(if *i == 0 {
if *i == 0 { "false".into() } else { "true".into() }, "false".into()
)), } else {
"true".into()
})),
other => unexpected_storage("bool", other), other => unexpected_storage("bool", other),
}, },
@@ -369,9 +367,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
} }
} else { } else {
CellOutcome::Incompatible { CellOutcome::Incompatible {
reason: format!( reason: format!("`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"),
"`{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(); let discarded = r - r.trunc();
CellOutcome::Lossy { CellOutcome::Lossy {
new: Value::Integer(truncated), new: Value::Integer(truncated),
reason: format!( reason: format!("truncated; would discard {}", format_real(discarded)),
"truncated; would discard {}",
format_real(discarded)
),
} }
} }
} }
@@ -555,9 +548,7 @@ fn format_real(r: f64) -> String {
fn unexpected_storage(label: &str, value: &Value) -> CellOutcome { fn unexpected_storage(label: &str, value: &Value) -> CellOutcome {
CellOutcome::Incompatible { CellOutcome::Incompatible {
reason: format!( reason: format!("internal: cell stored unexpectedly for `{label}` source ({value:?})"),
"internal: cell stored unexpectedly for `{label}` source ({value:?})"
),
} }
} }
@@ -638,7 +629,10 @@ mod tests {
(Type::Date, Type::Int), (Type::Date, Type::Int),
(Type::ShortId, 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), (Type::Bool, Type::Real),
]; ];
for (s, t) in pairs { 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 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` /// 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 inner_w = dialog_w.saturating_sub(4) as usize;
let prompt_lines = wrap_lines(&m.prompt, inner_w); let prompt_lines = wrap_lines(&m.prompt, inner_w);
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints. // 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 x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect { let dialog_area = Rect {
@@ -320,9 +331,7 @@ fn render_path_entry(
}; };
frame.render_widget(ratatui::widgets::Clear, dialog_area); frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default() let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
@@ -386,9 +395,7 @@ fn render_load_picker(
}; };
frame.render_widget(ratatui::widgets::Clear, dialog_area); frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default() let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
@@ -411,9 +418,7 @@ fn render_load_picker(
let marker = if i == m.selected { "›" } else { " " }; let marker = if i == m.selected { "›" } else { " " };
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" }; let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
let style = if i == m.selected { let style = if i == m.selected {
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(theme.fg) Style::default().fg(theme.fg)
}; };
@@ -447,11 +452,7 @@ fn render_load_picker(
let display_input = if *cursor == input.len() { let display_input = if *cursor == input.len() {
format!("{input}{cursor_marker}") format!("{input}{cursor_marker}")
} else { } else {
format!( format!("{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..])
"{}{cursor_marker}{}",
&input[..*cursor],
&input[*cursor..]
)
}; };
text_lines.push(Line::from(format!("> {display_input}"))); text_lines.push(Line::from(format!("> {display_input}")));
text_lines.push(Line::from("")); 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; let bg = ratatui::widgets::Clear;
frame.render_widget(bg, dialog_area); frame.render_widget(bg, dialog_area);
let title_style = Style::default() let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .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![ text_lines.push(Line::from(vec![
Span::styled( Span::styled(
"[Y]", "[Y]",
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
), ),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))), Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled( Span::styled(
"[N]", "[N]",
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
), ),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))), Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled("Esc", Style::default().fg(theme.muted)),
@@ -578,18 +573,14 @@ where
/// dialog (issue #13): wide enough to hold the longest content /// dialog (issue #13): wide enough to hold the longest content
/// line on a single row, clamped to sane bounds and the available /// line on a single row, clamped to sane bounds and the available
/// area so a short insert no longer wraps on roomy terminals. /// area so a short insert no longer wraps on roomy terminals.
fn undo_dialog_width( fn undo_dialog_width(content_widths: impl IntoIterator<Item = usize>, area_width: u16) -> u16 {
content_widths: impl IntoIterator<Item = usize>,
area_width: u16,
) -> u16 {
/// Floor — comfortably fits the button row plus borders. /// Floor — comfortably fits the button row plus borders.
const MIN: u16 = 34; const MIN: u16 = 34;
/// Ceiling for outlier (ultra-wide) terminals. /// Ceiling for outlier (ultra-wide) terminals.
const MAX: u16 = 100; const MAX: u16 = 100;
let widest = content_widths.into_iter().max().unwrap_or(0); let widest = content_widths.into_iter().max().unwrap_or(0);
// +4: left/right border (2) + one padding column each side (2). // +4: left/right border (2) + one padding column each side (2).
let preferred = let preferred = u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let upper = area_width.min(MAX); let upper = area_width.min(MAX);
let lower = MIN.min(upper); let lower = MIN.min(upper);
preferred.clamp(lower, upper) preferred.clamp(lower, upper)
@@ -617,8 +608,7 @@ fn render_undo_confirm(
let intro_line = format!("{intro} {}", m.command); let intro_line = format!("{intro} {}", m.command);
// Local-time, human-formatted snapshot stamp (issue #13). // Local-time, human-formatted snapshot stamp (issue #13).
let when_display = format_snapshot_timestamp(&m.timestamp); let when_display = format_snapshot_timestamp(&m.timestamp);
let when_line = let when_line = crate::t!("modal.undo_confirm_when", timestamp = when_display);
crate::t!("modal.undo_confirm_when", timestamp = when_display);
let prompt = crate::t!("modal.undo_confirm_prompt"); let prompt = crate::t!("modal.undo_confirm_prompt");
// Reconstruct the button row purely to measure its width — the // Reconstruct the button row purely to measure its width — the
// styled spans are built below. Keep this in sync with them. // 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(prompt));
text_lines.push(Line::from("")); text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![ 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::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::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled( 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) { fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let label_style = Style::default().fg(theme.muted); let label_style = Style::default().fg(theme.muted);
let value_style = Style::default() let value_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted); let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
let no_project = crate::t!("status.no_project"); let no_project = crate::t!("status.no_project");
let display = app.project_name.as_deref().unwrap_or(no_project.as_str()); let display = app.project_name.as_deref().unwrap_or(no_project.as_str());
let mut spans: Vec<Span<'_>> = vec![Span::styled( let mut spans: Vec<Span<'_>> =
crate::t!("status.project_label"), vec![Span::styled(crate::t!("status.project_label"), label_style)];
label_style,
)];
if app.project_is_temp { if app.project_is_temp {
spans.push(Span::styled( spans.push(Span::styled(
"[TEMP] ", "[TEMP] ",
@@ -875,9 +867,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
)) ))
.title(Span::styled( .title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")), format!(" {} ", crate::t!("panel.tables_title")),
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
)) ))
.style(Style::default().bg(theme.bg).fg(theme.fg)); .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(); let mut lines: Vec<Line<'_>> = Vec::new();
for name in &app.tables { for name in &app.tables {
let style = if name == highlight { let style = if name == highlight {
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(theme.fg) 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); frame.render_widget(paragraph, area);
} }
@@ -956,9 +946,7 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
)) ))
.title(Span::styled( .title(Span::styled(
format!(" {} ", crate::t!("panel.relationships_title")), format!(" {} ", crate::t!("panel.relationships_title")),
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
)) ))
.style(Style::default().bg(theme.bg).fg(theme.fg)); .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), ellipsize(&rel.name, inner_w),
name_style, name_style,
))); )));
let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", ")); let parent = format!(
lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style))); " {}.{} ->",
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(", ")); 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); 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)) .border_style(Style::default().fg(theme.border))
.title(Span::styled( .title(Span::styled(
format!(" {} ", crate::t!("panel.output_title")), format!(" {} ", crate::t!("panel.output_title")),
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
)) ))
.style(Style::default().bg(theme.bg).fg(theme.fg)); .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::Neutral => Style::new().fg(theme.fg),
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient), OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
OutputStyleClass::Expensive => Style::new().fg(theme.warning), OutputStyleClass::Expensive => Style::new().fg(theme.warning),
OutputStyleClass::AutomaticIndex => Style::new() OutputStyleClass::AutomaticIndex => {
.fg(theme.warning) Style::new().fg(theme.warning).add_modifier(Modifier::BOLD)
.add_modifier(Modifier::BOLD), }
// ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:` // ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:`
// prefix and every category-3 prose line (caveat + the // prefix and every category-3 prose line (caveat + the
// existing `client_side.*` notes). `theme.muted` is 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], &line.text[..prefix_len],
Style::default().fg(theme.muted), Style::default().fg(theme.muted),
)); ));
for run in for run in crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) {
crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced)
{
spans.push(Span::styled( spans.push(Span::styled(
&rest[run.byte_range.0..run.byte_range.1], &rest[run.byte_range.0..run.byte_range.1],
run.style, run.style,
@@ -1350,9 +1346,7 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
label, label,
Style::default() Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
.fg(mode_color)
.add_modifier(Modifier::BOLD),
), ),
Span::raw(" "), Span::raw(" "),
]); ]);
@@ -1474,7 +1468,10 @@ fn render_input_one_row(
if offset > 0 { if offset > 0 {
frame.render_widget( frame.render_widget(
Paragraph::new(Span::styled("<", marker)), Paragraph::new(Span::styled("<", marker)),
Rect { width: 1, ..text_area }, Rect {
width: 1,
..text_area
},
); );
} }
if offset + eff < line_cols { 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 // Overflowing both rows reserves a marker column on each row's
// outer edge; otherwise both rows use their full text width. // outer edge; otherwise both rows use their full text width.
let overflow = line_cols >= capacity; let overflow = line_cols >= capacity;
let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w }; let row0_text_w = if overflow {
let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w }; 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 eff_cap = row0_text_w + row1_text_w;
let start = offset.min(len); 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( frame.render_widget(
Paragraph::new(to_line(&window[..split])), Paragraph::new(to_line(&window[..split])),
Rect { Rect {
@@ -1622,10 +1631,7 @@ fn expand_runs_to_cells(
/// Convert `StyledRun`s into ratatui `Span`s borrowed from /// Convert `StyledRun`s into ratatui `Span`s borrowed from
/// `input`. The end-of-input cursor sentinel (empty range) is /// `input`. The end-of-input cursor sentinel (empty range) is
/// rendered as an inverted space. /// rendered as an inverted space.
fn runs_to_spans<'a>( fn runs_to_spans<'a>(input: &'a str, runs: &[crate::input_render::StyledRun]) -> Vec<Span<'a>> {
input: &'a str,
runs: &[crate::input_render::StyledRun],
) -> Vec<Span<'a>> {
runs.iter() runs.iter()
.map(|r| { .map(|r| {
if r.byte_range.0 == r.byte_range.1 { if r.byte_range.0 == r.byte_range.1 {
@@ -1710,21 +1716,14 @@ fn resolve_hint_lines(
} }
} }
fn render_hint_panel( fn render_hint_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect, lines: Vec<Line<'static>>) {
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
lines: Vec<Line<'static>>,
) {
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border)) .border_style(Style::default().fg(theme.border))
.title(Span::styled( .title(Span::styled(
format!(" {} ", crate::t!("panel.hint_title")), format!(" {} ", crate::t!("panel.hint_title")),
Style::default() Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
)) ))
.style(Style::default().bg(theme.bg).fg(theme.fg)); .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) { fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let key_style = Style::default() let key_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(theme.muted); let sep_style = Style::default().fg(theme.muted);
let label_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); let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
@@ -2016,9 +2013,16 @@ mod tests {
}; };
let rendered = render_output_line(&line, &theme); let rendered = render_output_line(&line, &theme);
// [system] tag, then the dim prefix, then ≥1 SQL spans. // [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[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!( assert_eq!(
rendered.spans[1].style.fg, rendered.spans[1].style.fg,
Some(theme.muted), Some(theme.muted),
@@ -2152,17 +2156,41 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass}; use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark(); let theme = Theme::dark();
let items = vec![ let items = vec![
Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both }, Candidate {
Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, text: "table".into(),
Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple }, 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); let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!(line.spans[0].content.as_ref(), "table"); 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].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].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] #[test]
@@ -2173,8 +2201,16 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass}; use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark(); let theme = Theme::dark();
let items = vec![ let items = vec![
Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, Candidate {
Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, 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); let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!( assert_eq!(
@@ -2248,7 +2284,10 @@ mod tests {
"the error body is neutral fg, not flooded red", "the error body is neutral fg, not flooded red",
); );
assert!( 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", "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}" "the tail around the cursor must be visible:\n{out}"
); );
assert!( 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}" "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] #[test]
@@ -2525,9 +2568,18 @@ mod tests {
let theme = Theme::dark(); let theme = Theme::dark();
// Narrow (sidebar hidden, DB1) so the line overflows the field. // Narrow (sidebar hidden, DB1) so the line overflows the field.
let out = render_to_string(&mut app, &theme, 60, 24); let out = render_to_string(&mut app, &theme, 60, 24);
assert!(out.contains("select * from"), "head visible at Home:\n{out}"); assert!(
assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}"); out.contains("select * from"),
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}"); "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 ----------- // ---- ADR-0046 DA4: two-row input on tall terminals -----------
@@ -2569,8 +2621,14 @@ mod tests {
let theme = Theme::dark(); let theme = Theme::dark();
// Very narrow + tall: two rows, but the line exceeds both. // Very narrow + tall: two rows, but the line exceeds both.
let out = render_to_string(&mut app, &theme, 38, 44); let out = render_to_string(&mut app, &theme, 38, 44);
assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}"); assert!(
assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}"); out.contains("Wonderland"),
"the tail/cursor stays visible:\n{out}"
);
assert!(
out.contains('<'),
"a left marker signals the hidden head:\n{out}"
);
} }
#[test] #[test]
@@ -2644,7 +2702,10 @@ mod tests {
/// The `key` column of the strip's bindings, in order. /// The `key` column of the strip's bindings, in order.
fn strip_keys(app: &App) -> Vec<&'static str> { 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). /// The full rendered strip text (keys + labels + separators).
@@ -2659,7 +2720,12 @@ mod tests {
fn hint_text(lines: &[Line<'_>]) -> String { fn hint_text(lines: &[Line<'_>]) -> String {
lines lines
.iter() .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<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
} }
@@ -2686,10 +2752,7 @@ mod tests {
fn strip_sidebar_focus_state_is_pane_scroll_input() { fn strip_sidebar_focus_state_is_pane_scroll_input() {
let mut app = App::new(); let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables; app.nav_focus = NavFocus::SidebarTables;
assert_eq!( assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],);
strip_keys(&app),
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
);
// ...and the relationships sidebar is the same state. // ...and the relationships sidebar is the same state.
app.nav_focus = NavFocus::SidebarRelationships; app.nav_focus = NavFocus::SidebarRelationships;
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]); 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"]); assert_eq!(two, vec!["alpha beta", "gamma delta"]);
// > max rows: clamp to max, last row ends with an ellipsis, // > max rows: clamp to max, last row ends with an ellipsis,
// and every row stays within the width. // and every row stays within the width.
let many = clamp_wrapped( let many = clamp_wrapped("alpha beta gamma delta epsilon zeta eta theta iota", 11, 3);
"alpha beta gamma delta epsilon zeta eta theta iota",
11,
3,
);
assert_eq!(many.len(), 3); assert_eq!(many.len(), 3);
assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}"); assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}");
for row in &many { for row in &many {
@@ -2931,9 +2990,18 @@ mod tests {
app.output.push_back(err); app.output.push_back(err);
let out = render_to_string(&mut app, &Theme::dark(), 100, 20); let out = render_to_string(&mut app, &Theme::dark(), 100, 20);
assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}"); assert!(
assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}"); out.contains("running: drop table Orders"),
assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}"); "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!( assert!(
!out.contains("running: create table"), !out.contains("running: create table"),
"a completed echo drops the running: prefix:\n{out}" "a completed echo drops the running: prefix:\n{out}"
@@ -2970,7 +3038,10 @@ mod tests {
#[test] #[test]
fn format_snapshot_timestamp_falls_back_on_garbage() { 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] #[test]
@@ -2999,9 +3070,9 @@ mod tests {
let theme = Theme::dark(); let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30); let out = render_to_string(&mut app, &theme, 120, 30);
assert!( assert!(
out.lines().any(|l| l.contains( out.lines()
"This will undo: insert into Customers values (1, 'Oliver Sturm')" .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}" "command must sit on one row on a wide terminal:\n{out}"
); );
} }
@@ -3017,7 +3088,10 @@ mod tests {
})); }));
let theme = Theme::dark(); let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30); 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("[Y] Yes"), "capitalized Yes:\n{out}");
assert!(out.contains("[N] No"), "capitalized No:\n{out}"); assert!(out.contains("[N] No"), "capitalized No:\n{out}");
assert!( assert!(
@@ -3113,8 +3187,14 @@ mod tests {
app.schema_cache.table_indexes.insert( app.schema_cache.table_indexes.insert(
"Customers".to_string(), "Customers".to_string(),
vec![ vec![
IndexEntry { name: "idx_email".to_string(), unique: false }, IndexEntry {
IndexEntry { name: "uidx_login".to_string(), unique: true }, name: "idx_email".to_string(),
unique: false,
},
IndexEntry {
name: "uidx_login".to_string(),
unique: true,
},
], ],
); );
let theme = Theme::dark(); let theme = Theme::dark();
@@ -3123,7 +3203,10 @@ mod tests {
assert!(out.contains("Customers"), "table listed:\n{out}"); assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "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("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] #[test]
@@ -3143,10 +3226,19 @@ mod tests {
app.tables = vec!["Customers".to_string()]; app.tables = vec!["Customers".to_string()];
let theme = Theme::dark(); let theme = Theme::dark();
let narrow = render_to_string(&mut app, &theme, 80, 24); 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); let wide = render_to_string(&mut app, &theme, 110, 24);
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}"); assert!(
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}"); wide.contains("Tables"),
"sidebar shown at 110 wide:\n{wide}"
);
assert!(
wide.contains("Customers"),
"tables listed when shown:\n{wide}"
);
} }
#[test] #[test]
@@ -3181,7 +3273,10 @@ mod tests {
let theme = Theme::dark(); let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 110, 24); let out = render_to_string(&mut app, &theme, 110, 24);
assert!(out.contains("Relationships"), "panel title present:\n{out}"); 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!( assert!(
out.lines().any(|l| l.contains("Customers.id ->")), out.lines().any(|l| l.contains("Customers.id ->")),
"parent endpoint, broken at the arrow:\n{out}" "parent endpoint, broken at the arrow:\n{out}"
@@ -3228,8 +3323,14 @@ mod tests {
app.nav_focus = NavFocus::SidebarTables; app.nav_focus = NavFocus::SidebarTables;
let focused = render_to_string(&mut app, &theme, 80, 24); let focused = render_to_string(&mut app, &theme, 80, 24);
assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}"); assert!(
assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}"); focused.contains("Tables"),
"sidebar revealed in nav mode:\n{focused}"
);
assert!(
focused.contains("Customers"),
"tables in the overlay:\n{focused}"
);
assert!( assert!(
focused.contains("Relationships"), focused.contains("Relationships"),
"relationships panel in the overlay:\n{focused}" "relationships panel in the overlay:\n{focused}"
@@ -3365,7 +3466,9 @@ mod tests {
} }
let backend = TestBackend::new(width, height); let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal"); 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() terminal.backend().buffer().clone()
} }
+15 -3
View File
@@ -737,7 +737,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root) let payload_dirs = fs::read_dir(&store.root)
.unwrap() .unwrap()
.filter_map(std::result::Result::ok) .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(); .count();
assert_eq!(payload_dirs, 2, "ring capped at 2 payloads"); assert_eq!(payload_dirs, 2, "ring capped at 2 payloads");
} }
@@ -766,7 +770,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root) let payload_dirs = fs::read_dir(&store.root)
.unwrap() .unwrap()
.filter_map(std::result::Result::ok) .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(); .count();
assert_eq!(payload_dirs, 1, "only the surviving undo payload remains"); 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(); fs::create_dir_all(store.payload_dir(41)).unwrap();
stage_finalize(&store, &fx.conn, "cmd"); stage_finalize(&store, &fx.conn, "cmd");
let meta = store.peek_undo().unwrap().unwrap(); 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) { fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let db = Database::open_with_persistence( let db = Database::open_with_persistence(
project.db_path(), project.db_path(),
Persistence::new(project.path().to_path_buf()), 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(); let r = rt();
r.block_on(db.create_table( r.block_on(db.create_table(
"Items".to_string(), "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()], vec!["id".to_string()],
Some("create".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)) .block_on(db.query_data("Items".to_string(), None, None))
.expect("query") .expect("query")
.rows; .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")); 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 db = fresh_rebuild(db, &project, &r);
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe"); let desc = r
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added"); .block_on(db.describe_table("Items".to_string()))
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild"); .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). // The CHECK is intact too (a negative qty is refused under the real table).
assert!( assert!(
r.block_on(db.insert( 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\ insert into Items (id, note) values (1, 'x')\n\
drop table items\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"); 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). // A fresh rebuild yields no Items (the metadata/yaml has no orphan).
let db = fresh_rebuild(db, &project, &r); 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", add 1:n relationship from parent.id to child.parent_id\n",
); );
// The parent's inbound relationship is visible under the stored case. // 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"); let p = r
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case"); .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"); assert_eq!(p.inbound_relationships[0].other_table, "Child");
let db = fresh_rebuild(db, &project, &r); let db = fresh_rebuild(db, &project, &r);
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent"); let p = r
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild"); .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"); 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) { fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true) let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true)
.expect("open db with persistence"); .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!["a < b".to_string()],
vec![], vec![],
false, 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"); .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)) .block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.expect_err("dropping a composite-UNIQUE column is refused"); .expect_err("dropping a composite-UNIQUE column is refused");
let msg = err.friendly_message(); 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!( assert!(
msg.contains("drop constraint unique_a_b"), msg.contains("drop constraint unique_a_b"),
"points at the actionable drop command; got: {msg}" "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); make_t_with_column_checks(&db, &r);
// `qty`'s own check `qty >= 0` references qty → refused. // `qty`'s own check `qty >= 0` references qty → refused.
assert!( assert!(
r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None)) r.block_on(db.rename_column(
.is_err(), "T".to_string(),
"qty".to_string(),
"amount".to_string(),
None
))
.is_err(),
"renaming a column with its own column-level CHECK is refused" "renaming a column with its own column-level CHECK is refused"
); );
// `price` is referenced by `discount`'s check `discount < price`. // `price` is referenced by `discount`'s check `discount < price`.
assert!( assert!(
r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None)) r.block_on(db.rename_column(
.is_err(), "T".to_string(),
"price".to_string(),
"cost".to_string(),
None
))
.is_err(),
"renaming a column referenced by another column's CHECK is refused" "renaming a column referenced by another column's CHECK is refused"
); );
// `id` is referenced by no CHECK → rename succeeds. // `id` is referenced by no CHECK → rename succeeds.
+51 -28
View File
@@ -9,7 +9,7 @@
use rdbms_playground::db::Database; use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ 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::persistence::Persistence;
use rdbms_playground::project; use rdbms_playground::project;
@@ -18,10 +18,8 @@ use rdbms_playground::project;
#[test] #[test]
fn parenthesized_compound_endpoint_parses_to_column_lists() { fn parenthesized_compound_endpoint_parses_to_column_lists() {
let cmd = parse_command( let cmd =
"add 1:n relationship from Parent.(a, b) to Child.(x, y)", parse_command("add 1:n relationship from Parent.(a, b) to Child.(x, y)").expect("parses");
)
.expect("parses");
match cmd { match cmd {
Command::AddRelationship { Command::AddRelationship {
parent_table, parent_table,
@@ -41,8 +39,7 @@ fn parenthesized_compound_endpoint_parses_to_column_lists() {
#[test] #[test]
fn single_column_endpoint_still_parses_unparenthesized() { fn single_column_endpoint_still_parses_unparenthesized() {
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid") let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid").expect("parses");
.expect("parses");
match cmd { match cmd {
Command::AddRelationship { Command::AddRelationship {
parent_columns, parent_columns,
@@ -148,7 +145,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
db.insert( db.insert(
"Region".to_string(), "Region".to_string(),
Some(vec!["country".to_string(), "code".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, None,
) )
.await .await
@@ -157,7 +157,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
.insert( .insert(
"City".to_string(), "City".to_string(),
Some(vec!["country".to_string(), "region_code".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, None,
) )
.await; .await;
@@ -176,8 +179,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -241,7 +243,10 @@ fn compound_fk_declares_enforces_and_round_trips() {
db.insert( db.insert(
"Region".to_string(), "Region".to_string(),
Some(vec!["country".to_string(), "code".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, None,
) )
.await .await
@@ -253,7 +258,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
"region_code".to_string(), "region_code".to_string(),
"name".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, None,
) )
.await .await
@@ -266,7 +275,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
"region_code".to_string(), "region_code".to_string(),
"name".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, None,
) )
.await; .await;
@@ -360,7 +373,10 @@ fn compound_fk_arity_mismatch_is_refused() {
None, None,
) )
.await; .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"); .expect("create Region");
// Parse the inline form so the `inline` flag is set by the grammar. // Parse the inline form so the `inline` flag is set by the grammar.
let cmd = parse_command( let cmd = parse_command("create table City (country int references Region(country, code))")
"create table City (country int references Region(country, code))", .expect("parses");
)
.expect("parses");
let Command::SqlCreateTable { let Command::SqlCreateTable {
name, name,
columns, columns,
@@ -465,7 +479,10 @@ fn compound_fk_type_mismatch_per_pair_is_refused() {
None, None,
) )
.await; .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 path = project.path().to_path_buf();
let rt = rt(); let rt = rt();
{ {
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .expect("open db");
Persistence::new(path.clone()),
)
.expect("open db");
rt.block_on(async { rt.block_on(async {
seed_compound(&db).await; seed_compound(&db).await;
db.add_relationship( db.add_relationship(
@@ -512,7 +526,10 @@ fn compound_fk_survives_rebuild_from_text() {
db.insert( db.insert(
"Region".to_string(), "Region".to_string(),
Some(vec!["country".to_string(), "code".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, None,
) )
.await .await
@@ -521,11 +538,17 @@ fn compound_fk_survives_rebuild_from_text() {
.insert( .insert(
"City".to_string(), "City".to_string(),
Some(vec!["country".to_string(), "region_code".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, None,
) )
.await; .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. // Endpoints survived the round-trip intact.
let city = db.describe_table("City".to_string()).await.unwrap(); let city = db.describe_table("City".to_string()).await.unwrap();
assert_eq!( assert_eq!(
+4 -9
View File
@@ -32,10 +32,8 @@ use rdbms_playground::event::AppEvent;
const FORBIDDEN: &[&str] = &[ const FORBIDDEN: &[&str] = &[
// Product names. // Product names.
"SQLite", "sqlite", "SQLite", "sqlite", // Crate name.
// Crate name. "rusqlite", // Engine-specific keywords / idioms.
"rusqlite",
// Engine-specific keywords / idioms.
"STRICT", "PRAGMA", "STRICT", "PRAGMA",
]; ];
@@ -52,9 +50,7 @@ fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> {
fn assert_clean(label: &str, s: &str) { fn assert_clean(label: &str, s: &str) {
if let Some((needle, pos)) = engine_vocab_leak(s) { if let Some((needle, pos)) = engine_vocab_leak(s) {
panic!( panic!("ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}");
"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", "this is not a command",
]; ];
for input in inputs { for input in inputs {
let err = parse_command(input) let err = parse_command(input).expect_err(&format!("expected parse failure for `{input}`"));
.expect_err(&format!("expected parse failure for `{input}`"));
let rendered = format!("{err:?}"); let rendered = format!("{err:?}");
assert_clean(&format!("parse error for `{input}`"), &rendered); assert_clean(&format!("parse error for `{input}`"), &rendered);
} }
+59 -25
View File
@@ -18,10 +18,10 @@
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use rdbms_playground::db::{Database, DbError, SqliteErrorKind}; 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::parser::parse_command;
use rdbms_playground::dsl::{
ColumnSpec, Command, RowFilter, Type, Value, action::ReferentialAction,
};
use rdbms_playground::runtime::enrich_dsl_failure; use rdbms_playground::runtime::enrich_dsl_failure;
fn rt() -> Runtime { fn rt() -> Runtime {
@@ -57,7 +57,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
db.insert( db.insert(
"Customers".to_string(), "Customers".to_string(),
None, None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())], vec![
Value::Number("5".to_string()),
Value::Text("Alice".to_string()),
],
None, None,
) )
.await .await
@@ -86,7 +89,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. } DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
)); ));
let facts = enrich_dsl_failure(&db, &cmd, &err).await; 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( db.insert(
"Customers".to_string(), "Customers".to_string(),
None, None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())], vec![
Value::Number("5".to_string()),
Value::Text("Alice".to_string()),
],
None, None,
) )
.await .await
@@ -189,7 +198,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
else { else {
panic!("expected Command::SqlInsert, got {cmd:?}"); 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 let err = db
.run_sql_insert_with_literals( .run_sql_insert_with_literals(
sql, sql,
@@ -204,7 +216,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. } DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
)); ));
let facts = enrich_dsl_failure(&db, &cmd, &err).await; let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -235,7 +250,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
db.insert( db.insert(
"Customers".to_string(), "Customers".to_string(),
None, None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())], vec![
Value::Number("1".to_string()),
Value::Text("Alice".to_string()),
],
None, None,
) )
.await .await
@@ -243,7 +261,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
db.insert( db.insert(
"Customers".to_string(), "Customers".to_string(),
None, None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())], vec![
Value::Number("2".to_string()),
Value::Text("Bob".to_string()),
],
None, None,
) )
.await .await
@@ -294,7 +315,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
db.insert( db.insert(
"Customers".to_string(), "Customers".to_string(),
None, None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())], vec![
Value::Number("1".to_string()),
Value::Text("Alice".to_string()),
],
None, None,
) )
.await .await
@@ -302,7 +326,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
db.insert( db.insert(
"Customers".to_string(), "Customers".to_string(),
None, None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())], vec![
Value::Number("2".to_string()),
Value::Text("Bob".to_string()),
],
None, None,
) )
.await .await
@@ -328,7 +355,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. } DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
)); ));
let facts = enrich_dsl_failure(&db, &cmd, &err).await; let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -666,7 +696,10 @@ fn enrich_fk_delete_resolves_child_table() {
db.insert( db.insert(
"Orders".to_string(), "Orders".to_string(),
None, None,
vec![Value::Number("1".to_string()), Value::Number("1".to_string())], vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None, None,
) )
.await .await
@@ -708,16 +741,15 @@ fn enrich_check_insert_resolves_table_column_value_and_rule() {
) )
.await .await
.unwrap(); .unwrap();
let score_spec = match parse_command( let score_spec =
"create table __probe with pk score(int) check (score >= 0)", match parse_command("create table __probe with pk score(int) check (score >= 0)")
) .expect("probe create parses")
.expect("probe create parses") {
{ Command::CreateTable { columns, .. } => {
Command::CreateTable { columns, .. } => { columns.into_iter().next().expect("one column")
columns.into_iter().next().expect("one column") }
} other => panic!("expected CreateTable, got {other:?}"),
other => panic!("expected CreateTable, got {other:?}"), };
};
db.add_column("Scores".to_string(), score_spec, None) db.add_column("Scores".to_string(), score_spec, None)
.await .await
.unwrap(); .unwrap();
@@ -757,7 +789,9 @@ fn enrich_unsupported_returns_default_facts() {
let db = db(); let db = db();
rt().block_on(async { rt().block_on(async {
let err = DbError::Unsupported("nope".to_string()); 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; let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert!(facts.table.is_none()); assert!(facts.table.is_none());
assert!(facts.column.is_none()); assert!(facts.column.is_none());
+1 -1
View File
@@ -11,7 +11,7 @@
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::app::App; 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; use rdbms_playground::event::AppEvent;
const fn key(code: KeyCode) -> 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::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value}; use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence; use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{ use rdbms_playground::project::{self, DATA_DIR, PROJECT_YAML};
self, DATA_DIR, PROJECT_YAML,
};
fn tempdir() -> tempfile::TempDir { fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir") tempfile::tempdir().expect("create tempdir")
@@ -33,9 +31,7 @@ fn rt() -> tokio::runtime::Runtime {
/// `Database` (with persistence wired) plus the path so the /// `Database` (with persistence wired) plus the path so the
/// test can inspect on-disk state. The project is held alive /// test can inspect on-disk state. The project is held alive
/// implicitly via the leaked `TempDir` returned alongside. /// implicitly via the leaked `TempDir` returned alongside.
fn open_project( fn open_project(data: &tempfile::TempDir) -> (project::Project, Database, std::path::PathBuf) {
data: &tempfile::TempDir,
) -> (project::Project, Database, std::path::PathBuf) {
let project = project::open_or_create(None, Some(data.path())).expect("open project"); let project = project::open_or_create(None, Some(data.path())).expect("open project");
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let persistence = Persistence::new(path.clone()); let persistence = Persistence::new(path.clone());
@@ -72,7 +68,10 @@ fn create_table_writes_yaml_and_history() {
}); });
let yaml = read_yaml(&path); 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("primary_key: [id]"), "yaml: {yaml}");
assert!(yaml.contains("type: serial"), "yaml: {yaml}"); assert!(yaml.contains("type: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}"); assert!(yaml.contains("type: text"), "yaml: {yaml}");
@@ -151,9 +150,15 @@ fn drop_table_removes_its_csv() {
.unwrap(); .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); 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] #[test]
@@ -263,7 +268,10 @@ fn create_table_does_not_write_csv_for_empty_table() {
// Schema landed in YAML. // Schema landed in YAML.
let yaml = read_yaml(&path); 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. // ...but no CSV until there's data.
assert!( assert!(
read_csv(&path, "Customers").is_none(), read_csv(&path, "Customers").is_none(),
@@ -394,7 +402,10 @@ fn project_yaml_carries_relationship_after_add() {
}); });
let yaml = read_yaml(&path); 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_delete: cascade"), "yaml: {yaml}");
assert!(yaml.contains("on_update: no_action"), "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_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .unwrap();
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"Customers".to_string(), "Customers".to_string(),
@@ -89,11 +86,8 @@ fn rebuild_restores_rows_from_csv() {
let project_path = { let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .unwrap();
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"Customers".to_string(), "Customers".to_string(),
@@ -157,11 +151,8 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
let project_path = { let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .unwrap();
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"Customers".to_string(), "Customers".to_string(),
@@ -244,7 +235,11 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
}) })
.expect("delete"); .expect("delete");
assert_eq!(result.rows_affected, 1); 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"); 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_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .unwrap();
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"Numbers".to_string(), "Numbers".to_string(),
@@ -303,13 +295,17 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
.unwrap(); .unwrap();
let err = rt() let err = rt()
.block_on(async { .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"); .expect_err("must fail with row-level error");
let msg = format!("{err}"); let msg = format!("{err}");
assert!(msg.contains("row 2"), "msg should name the row: {msg}"); assert!(msg.contains("row 2"), "msg should name the row: {msg}");
assert!(msg.contains("Numbers"), "msg should name the table: {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] #[test]
@@ -318,11 +314,8 @@ fn rebuild_preserves_created_at_from_yaml() {
let project_path = { let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .unwrap();
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"T".to_string(), "T".to_string(),
@@ -371,9 +364,7 @@ fn rebuild_preserves_created_at_from_yaml() {
// Trigger any successful command so project.yaml is // Trigger any successful command so project.yaml is
// rewritten from the now-rebuilt db state. // rewritten from the now-rebuilt db state.
rt().block_on(async { rt().block_on(async {
db.describe_table("T".to_string()) db.describe_table("T".to_string()).await.unwrap();
.await
.unwrap();
// describe is read-only; force a rewrite by adding a column. // describe is read-only; force a rewrite by adding a column.
db.add_column( db.add_column(
"T".to_string(), "T".to_string(),
@@ -400,11 +391,8 @@ fn rebuild_restores_indexes() {
let project_path = { let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .unwrap();
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"Customers".to_string(), "Customers".to_string(),
@@ -434,7 +422,10 @@ fn rebuild_restores_indexes() {
// The index must be recorded in project.yaml — the `.db` is // The index must be recorded in project.yaml — the `.db` is
// a derived artifact and gets discarded next. // a derived artifact and gets discarded next.
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap(); 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(); 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. // field while the modal is up.
app.update(key(KeyCode::Char('x'))); app.update(key(KeyCode::Char('x')));
assert!(app.input.is_empty(), "modal should swallow key input"); 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] #[test]
@@ -122,11 +125,8 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
let project_path = { let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
project.db_path(), .unwrap();
Persistence::new(path.clone()),
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"Customers".to_string(), "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. // Hand-edit the CSV to introduce a different row content.
// Rebuild should pick up the edited content. // Rebuild should pick up the edited content.
let csv_path = project_path.join("data").join("Customers.csv"); 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(); fs::write(&csv_path, edited).unwrap();
// Reopen with persistence (the .db still exists but has // 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, App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
PathEntryPurpose, PathEntryPurpose,
}; };
use rdbms_playground::event::AppEvent;
use rdbms_playground::db::Database; use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type}; use rdbms_playground::dsl::{ColumnSpec, Type};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence; use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{ use rdbms_playground::project::{
self, Project, ProjectKind, copy_project, safely_delete_temp_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 data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap(); let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf(); let path = project.path().to_path_buf();
let db = Database::open_with_persistence( let db =
project.db_path(), Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())).unwrap();
Persistence::new(path.clone()),
)
.unwrap();
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
+10 -8
View File
@@ -14,8 +14,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action; use rdbms_playground::action::Action;
use rdbms_playground::app::App; use rdbms_playground::app::App;
use rdbms_playground::archive::{ use rdbms_playground::archive::{
default_export_filename, export_project, extract_into, inspect_zip, default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence,
next_export_sequence, resolve_import_target, resolve_import_target,
}; };
use rdbms_playground::event::AppEvent; use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML}; 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. // Build a populated source project.
let src_path = { let src_path = {
let p = project::Project::create_named(&data.path().join("Source")).unwrap(); let p = project::Project::create_named(&data.path().join("Source")).unwrap();
let db = Database::open_with_persistence( let db =
p.db_path(), Database::open_with_persistence(p.db_path(), Persistence::new(p.path().to_path_buf()))
Persistence::new(p.path().to_path_buf()), .unwrap();
)
.unwrap();
rt().block_on(async { rt().block_on(async {
db.create_table( db.create_table(
"Customers".to_string(), "Customers".to_string(),
@@ -362,7 +360,11 @@ fn end_to_end_export_then_import_real_project() {
// Round-trip: the inserted row is back. // Round-trip: the inserted row is back.
let data_view = rt() 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"); .expect("query data");
assert_eq!(data_view.rows.len(), 1); assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value. // 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 project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf()); let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk", false).unwrap(); 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(); p.append_history("show data A", false).unwrap();
let entries = p.read_recent_history(10).unwrap(); let entries = p.read_recent_history(10).unwrap();
assert_eq!( assert_eq!(
+165 -45
View File
@@ -8,7 +8,7 @@
use rdbms_playground::db::Database; use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::RowFilter; 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::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB}; use rdbms_playground::project::{self, PLAYGROUND_DB};
@@ -22,8 +22,11 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) { fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project"); 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())) let db = Database::open_with_persistence(
.expect("db"); project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("db");
(project, db, dir) (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) { async fn serial_pk_table(db: &Database, name: &str) {
db.create_table( db.create_table(
name.to_string(), 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()], vec!["id".to_string()],
None, None,
) )
@@ -84,7 +90,9 @@ fn parses_with_as_name() {
match parse_command("create m:n relationship from Students to Courses as Enrollments") match parse_command("create m:n relationship from Students to Courses as Enrollments")
.expect("parses") .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:?}"), 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. // Auto-named `Students_Courses` exists.
let tables = db.list_tables().await.unwrap(); 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. // Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap(); let desc = db
let cols: Vec<(&str, bool)> = .describe_table("Students_Courses".to_string())
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect(); .await
.unwrap();
let cols: Vec<(&str, bool)> = desc
.columns
.iter()
.map(|c| (c.name.as_str(), c.primary_key))
.collect();
assert_eq!( assert_eq!(
cols, cols,
vec![("Students_id", true), ("Courses_id", true)], vec![("Students_id", true), ("Courses_id", true)],
@@ -124,7 +141,10 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
db.insert( db.insert(
"Students_Courses".to_string(), "Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".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, None,
) )
.await .await
@@ -134,21 +154,33 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
.insert( .insert(
"Students_Courses".to_string(), "Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".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, None,
) )
.await; .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. // A link to a non-existent parent is refused by the FK.
let orphan = db let orphan = db
.insert( .insert(
"Students_Courses".to_string(), "Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".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, None,
) )
.await; .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 .await
.expect("create m:n as Enrollments"); .expect("create m:n as Enrollments");
let tables = db.list_tables().await.unwrap(); 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())); 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). // Sections has a 2-column PK (course_id, term).
db.create_table( db.create_table(
"Sections".to_string(), "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()], vec!["course_id".to_string(), "term".to_string()],
None, None,
) )
@@ -191,11 +229,20 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
.await .await
.expect("create m:n"); .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(); 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. // 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( db.insert(
"Students_Courses".to_string(), "Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".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, None,
) )
.await .await
.unwrap(); .unwrap();
// Deleting the student cascades to the junction (ON DELETE CASCADE). // Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap(); db.delete("Students".to_string(), RowFilter::AllRows, None)
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap(); .await
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows); .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 .await
.unwrap(); .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. // One undo removes the junction table AND both relationships.
db.undo().await.unwrap(); db.undo().await.unwrap();
let tables = db.list_tables().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). // The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string()).await.unwrap(); 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) .create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
.await .await
.expect_err("self m:n must be refused"); .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 { rt().block_on(async {
serial_pk_table(&db, "Students").await; serial_pk_table(&db, "Students").await;
let err = db 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 .await
.expect_err("a missing parent table must be refused"); .expect_err("a missing parent table must be refused");
// The standard "no such table" guard (require_canonical_table). // 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) .create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await .await
.expect_err("a junction-name collision must be refused"); .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) db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await .await
.unwrap(); .unwrap();
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None) db.rename_table(
.await "Students_Courses".to_string(),
.expect("rename the junction"); "Enrollments".to_string(),
None,
)
.await
.expect("rename the junction");
let tables = db.list_tables().await.unwrap(); 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())); assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving). // Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string()).await.unwrap(); 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. // Discard the derived .db so the next open rebuilds from text.
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap(); std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap(); let project = project::Project::open(&project_path).unwrap();
let db = let db = Database::open_with_persistence(
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf())) project.db_path(),
.unwrap(); Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async { 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(); let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}"); assert!(
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap(); tables.contains(&"Students_Courses".to_string()),
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed"); "junction survived: {tables:?}"
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed"); );
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 .await
.expect_err("an internal junction name must be refused"); .expect_err("an internal junction name must be refused");
assert!(format!("{err}").contains("no such table"), "got: {err}"); 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. // Both have the junction (Students_Courses) as their child.
for r in &rels { 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. // One points back to each parent.
let parents: std::collections::BTreeSet<&str> = let parents: std::collections::BTreeSet<&str> =
+1 -1
View File
@@ -24,6 +24,7 @@ mod parse_error_pedagogy;
mod project_lifecycle; mod project_lifecycle;
mod replay_command; mod replay_command;
mod seed; mod seed;
mod show_list;
mod sql_alter_table; mod sql_alter_table;
mod sql_create_index; mod sql_create_index;
mod sql_create_table; mod sql_create_table;
@@ -34,6 +35,5 @@ mod sql_drop_table;
mod sql_insert; mod sql_insert;
mod sql_select; mod sql_select;
mod sql_update; mod sql_update;
mod show_list;
mod undo_snapshots; mod undo_snapshots;
mod walking_skeleton; 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 { fn dump(input: &str, lines: &[String]) -> String {
format!( format!("INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"),)
"INPUT: {input:?}\nERROR LINES:\n{}",
lines.join("\n"),
)
} }
/// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a /// 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 // app-lifecycle arg errors. The arg-less commands all reject
// trailing junk with "expected end of input" + their usage // trailing junk with "expected end of input" + their usage
// (audited 2026-06-05); locked here as regression insurance. // (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` now takes an optional single-word topic (H3), so
// `help foo` parses (topic lookup); only a *multi-word* // `help foo` parses (topic lookup); only a *multi-word*
// topic is the near-miss that rejects trailing junk. // 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"]), ("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"]), "load foo",
("redo foo", &["after `redo`, expected end of input", " redo"]), &["after `load`, expected end of input", " load"],
("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"]), "undo foo",
("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]), &["after `undo`, expected end of input", " undo"],
("messages louder", &["unknown messages mode 'louder'", "messages short"]), ),
("copy everything", &["unknown copy target 'everything'", "copy all"]), (
"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 // 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",
("create table T", &["with pk", "create table <Name> with pk"]), &[
"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. // 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",
("add column", &["after `add column`, expected table name", "add column [to] [table]"]), &[
("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]), "after `add`, expected `column`, `1:n relationship`",
("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]), "add 1:n relationship",
("change", &["after `change`, expected `column`", "change column [in] [table]"]), ],
("change column", &["after `change column`, expected table name", "change column [in] [table]"]), ),
(
"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 // data bare + missing-clause
("insert", &["after `insert`, expected `into`", "insert into <Table>"]), (
("insert into", &["after `insert into`, expected table name", "insert into <Table>"]), "insert",
("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into <Table>"]), &["after `insert`, expected `into`", "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"]), "insert into",
("delete", &["after `delete`, expected `from`", "delete from <Table>"]), &[
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]), "after `insert into`, expected table name",
("delete from T", &["expected `where` or `--all-rows`", "delete from <Table>"]), "insert into <Table>",
("seed", &["after `seed`, expected table name", "seed <Table> [count]"]), ],
),
(
"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. // 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", "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 // advanced-only entry word typed in simple mode → "this is SQL" rail
("select * from T", &["`select` is SQL", "mode advanced"]), ("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 { for (input, needles) in matrix {
let lines = error_lines_for(input); let lines = error_lines_for(input);
@@ -164,26 +332,160 @@ fn near_miss_matrix_committed_multiforms() {
// (input, advanced?, required-substrings) // (input, advanced?, required-substrings)
let matrix: &[(&str, bool, &[&str])] = &[ let matrix: &[(&str, bool, &[&str])] = &[
// add / drop multi-forms (simple) // 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 index",
("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]), false,
("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"]), "after `add index`, expected `on` or `as`",
("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from <Parent>.<col>"]), "add index [as <Name>] on",
("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>"]), "add index on T",
("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship <Name>"]), false,
("show table", false, &["after `show table`, expected table name", "show table <Table>"]), &[
("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship <name>"]), "after `add index on T`, expected `(`",
("show index", false, &["after `show index`, expected index name", "show index <name>"]), "add index [as <Name>] on",
("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]), ],
),
(
"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 // 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"]), "create index on",
("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table <Table> add column"]), true,
("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table <Table> drop column"]), &[
"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 { for (input, advanced, needles) in matrix {
let lines = if *advanced { let lines = if *advanced {
@@ -265,7 +567,10 @@ fn advanced_mode_usage_block_shows_sql_and_dsl_forms() {
// (mode-primary first). // (mode-primary first).
let sql_at = joined.find("create table [if not exists]").unwrap(); let sql_at = joined.find("create table [if not exists]").unwrap();
let dsl_at = joined.find("create table <Name> with pk").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] #[test]
@@ -307,23 +612,94 @@ fn advanced_cross_join_with_on_teaches_no_on_clause() {
fn near_miss_matrix_advanced_mode() { fn near_miss_matrix_advanced_mode() {
let matrix: &[(&str, &[&str])] = &[ let matrix: &[(&str, &[&str])] = &[
// SQL select / with (G2, G4) // SQL select / with (G2, G4)
("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]), (
("select * from", &["after `select * from`, expected table name", "select (* |"]), "select",
("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]), &[
"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 // create / drop / alter — SQL forms AND the still-valid DSL
// fallback forms, SQL-primary first (G3). // 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",
("create index", &["after `create index`, expected `on`", "create [unique] index"]), &[
("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]), "after `create`, expected `table`",
("alter", &["after `alter`, expected `table`", "alter table <Table> add column"]), "create table [if not exists]",
("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table <Table>"]), "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 // shared insert/update/delete — must show usage, not the
// available-commands fallback (regression guard for the // available-commands fallback (regression guard for the
// empty-usage_ids SQL nodes). // 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"]), "insert into T",
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]), &[
"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 { for (input, needles) in matrix {
let lines = advanced_error_lines_for(input); let lines = advanced_error_lines_for(input);
@@ -365,7 +741,9 @@ fn with_alone_renders_cte_usage_not_select() {
.collect(); .collect();
let dump_msg = dump("with", &lines); let dump_msg = dump("with", &lines);
assert!( 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}", "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}", "missing usage: header\n{dump_msg}",
); );
assert!( 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}", "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}")); .unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
// The list must include all ten command-entry keywords. // The list must include all ten command-entry keywords.
for cmd in [ for cmd in [
"add", "change", "create", "delete", "drop", "insert", "add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update",
"rename", "replay", "show", "update",
] { ] {
assert!( assert!(
available.contains(&format!("`{cmd}`")), 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:?}", "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] #[test]
fn no_args_creates_temp_project_under_data_root() { fn no_args_creates_temp_project_under_data_root() {
let data = tempdir(); let data = tempdir();
let project = project::open_or_create(None, Some(data.path())) let project =
.expect("open_or_create with empty CLI"); project::open_or_create(None, Some(data.path())).expect("open_or_create with empty CLI");
let path = project.path(); let path = project.path();
assert!(path.exists(), "project dir should exist"); assert!(path.exists(), "project dir should exist");
assert!(path.starts_with(data.path())); assert!(path.starts_with(data.path()));
assert_eq!( 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()), 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 // Now drive open_or_create with the path as if it were a
// CLI positional argument. // CLI positional argument.
let project = project::open_or_create(Some(&path), None) let project = project::open_or_create(Some(&path), None).expect("open via positional path");
.expect("open via positional path");
assert_eq!(project.path(), 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!(p1_path.starts_with(data.path()));
assert!(p2_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] #[test]
@@ -167,11 +171,18 @@ fn db_persists_across_open_close_cycles() {
db.create_table( db.create_table(
"Customers".to_string(), "Customers".to_string(),
vec![ vec![
rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial), rdbms_playground::dsl::ColumnSpec::new(
rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text), "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()], vec!["id".to_string()],
None) None,
)
.await .await
.expect("create_table"); .expect("create_table");
}); });
@@ -187,7 +198,9 @@ fn db_persists_across_open_close_cycles() {
.enable_all() .enable_all()
.build() .build()
.unwrap(); .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:?}"); assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}");
// Sanity: the project.yaml and history.log are still empty // 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 /// harness — most tests only need to write a script file and
/// call `run_replay`. /// call `run_replay`.
fn open_project_db(data_root: &Path) -> (project::Project, Database) { fn open_project_db(data_root: &Path) -> (project::Project, Database) {
let project = project::open_or_create(None, Some(data_root)) let project = project::open_or_create(None, Some(data_root)).expect("open_or_create");
.expect("open_or_create");
let db = Database::open_with_persistence( let db = Database::open_with_persistence(
project.db_path(), project.db_path(),
Persistence::new(project.path().to_path_buf()), Persistence::new(project.path().to_path_buf()),
@@ -132,9 +131,7 @@ fn replay_three_lines_dispatches_three_commands() {
insert into T (1, 'Alice')\n", insert into T (1, 'Alice')\n",
); );
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
run_replay(&db, project.path(), "seed.commands").await
});
assert_completed(&events, 3); assert_completed(&events, 3);
// The dispatched commands actually mutated state. // 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", 2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
); );
let events = let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three `ok` records replayed; the `err` record is skipped (not // Three `ok` records replayed; the `err` record is skipped (not
// counted, not a failure). // counted, not a failure).
assert_completed(&events, 3); 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:13Z|ok|add column T: v (text)\n\
2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n", 2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n",
); );
let events = let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three data/schema commands ran; every app-lifecycle line was // Three data/schema commands ran; every app-lifecycle line was
// skipped silently (no panic, no abort, no warnings, no quit). // skipped silently (no panic, no abort, no warnings, no quit).
match events.last().expect("event") { match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => { AppEvent::ReplayCompleted {
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}"); count, warnings, ..
assert!(warnings.is_empty(), "these skips are silent; got {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:?}"), 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:00Z|ok|create table T with pk id(int)\n\
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n", 2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
); );
let events = let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
match events.last().expect("event") { match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => { AppEvent::ReplayCompleted {
count, warnings, ..
} => {
assert_eq!(*count, 1, "only the create ran; events: {events:?}"); assert_eq!(*count, 1, "only the create ran; events: {events:?}");
assert!( assert!(
warnings.iter().any(|w| w.contains("import shared.zip")), warnings.iter().any(|w| w.contains("import shared.zip")),
@@ -282,9 +286,7 @@ fn replay_skips_blank_lines_and_comments() {
\n", \n",
); );
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
run_replay(&db, project.path(), "seed.commands").await
});
// Only two non-blank, non-comment lines. // Only two non-blank, non-comment lines.
assert_completed(&events, 2); assert_completed(&events, 2);
} }
@@ -295,9 +297,7 @@ fn replay_empty_file_completes_with_zero_commands() {
let (project, db) = open_project_db(data.path()); let (project, db) = open_project_db(data.path());
write_script(project.path(), "empty.commands", ""); write_script(project.path(), "empty.commands", "");
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "empty.commands").await });
run_replay(&db, project.path(), "empty.commands").await
});
assert_completed(&events, 0); assert_completed(&events, 0);
} }
@@ -311,9 +311,8 @@ fn replay_only_comments_completes_with_zero_commands() {
"# just\n# comments\n\n", "# just\n# comments\n\n",
); );
let events = rt().block_on(async { let events =
run_replay(&db, project.path(), "comments.commands").await rt().block_on(async { run_replay(&db, project.path(), "comments.commands").await });
});
assert_completed(&events, 0); 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 // INSERT command — the **real offending value** is shown too (it used
// to degrade to the neutral "that value" because `SqlInsert` discarded // to degrade to the neutral "that value" because `SqlInsert` discarded
// its literals). // its literals).
assert!(error.contains("T.email"), "names the real table.column; got: {error}"); assert!(
assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}"); 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] #[test]
@@ -359,9 +364,8 @@ fn replay_missing_file_fails_with_line_number_zero() {
let data = tempdir(); let data = tempdir();
let (project, db) = open_project_db(data.path()); let (project, db) = open_project_db(data.path());
let events = rt().block_on(async { let events =
run_replay(&db, project.path(), "no-such-file.commands").await rt().block_on(async { run_replay(&db, project.path(), "no-such-file.commands").await });
});
let failed = assert_failed_at(&events, 0); let failed = assert_failed_at(&events, 0);
let AppEvent::ReplayFailed { error, .. } = failed else { let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!() unreachable!()
@@ -387,9 +391,7 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
insert into T (1, 'should not happen')\n", insert into T (1, 'should not happen')\n",
); );
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
run_replay(&db, project.path(), "bad.commands").await
});
let failed = assert_failed_at(&events, 3); let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, command, .. } = failed else { let AppEvent::ReplayFailed { error, command, .. } = failed else {
unreachable!() 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", insert into T values (1, 'not a number')\n",
); );
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "typed.commands").await });
run_replay(&db, project.path(), "typed.commands").await
});
let failed = assert_failed_at(&events, 3); let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, .. } = failed else { let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!() unreachable!()
@@ -489,9 +489,7 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() {
insert into T (1)\n", insert into T (1)\n",
); );
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
run_replay(&db, project.path(), "bad.commands").await
});
let _ = assert_failed_at(&events, 2); 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. // because the nested file's commands are not reconstructed.
let data = tempdir(); let data = tempdir();
let (project, db) = open_project_db(data.path()); 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( write_script(
project.path(), project.path(),
"outer.commands", "outer.commands",
"create table U with pk id(int)\nreplay inner.commands\n", "create table U with pk id(int)\nreplay inner.commands\n",
); );
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "outer.commands").await });
run_replay(&db, project.path(), "outer.commands").await
});
// The outer `create table U` ran; the nested `replay` was // The outer `create table U` ran; the nested `replay` was
// skipped (count 1), with a warning. // skipped (count 1), with a warning.
match events.last().expect("event") { 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_eq!(*count, 1, "only the outer create ran; events: {events:?}");
assert!( 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:?}", "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). // 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 }); 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] #[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", "create table T with pk id(int)\nadd column T: name (text)\n",
); );
let events = rt().block_on(async { let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
run_replay(&db, project.path(), "seed.commands").await
});
assert_completed(&events, 2); assert_completed(&events, 2);
let history = fs::read_to_string(project.path().join("history.log")) let history =
.expect("history.log exists"); fs::read_to_string(project.path().join("history.log")).expect("history.log exists");
// Per-command entries landed. // Per-command entries landed.
assert!( 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}" "history.log missing create line:\n{history}"
); );
assert!( 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}" "history.log missing add column line:\n{history}"
); );
// The replay invocation itself did NOT land — that's // 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) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with 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") { match parse_command("seed People").expect("`seed People` parses") {
Command::Seed { table, count, .. } => { Command::Seed { table, count, .. } => {
assert_eq!(table, "People"); 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:?}"), 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'"); let (_t, ov) = seed_overrides("seed users 5 set status = 'active'");
assert_eq!(ov.len(), 1); assert_eq!(ov.len(), 1);
assert_eq!(ov[0].column, "status"); 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] #[test]
@@ -177,8 +182,7 @@ fn seed_set_numeric_range_override_parses() {
#[test] #[test]
fn seed_set_date_range_override_parses_with_quoted_dates() { fn seed_set_date_range_override_parses_with_quoted_dates() {
// ADR-0048 D2 amendment: dates in the range form are quoted strings. // ADR-0048 D2 amendment: dates in the range form are quoted strings.
let (_t, ov) = let (_t, ov) = seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
assert_eq!( assert_eq!(
ov[0].kind, ov[0].kind,
SeedOverrideKind::Range { 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 — // No positional count, but `between 18 and 80` carries NumberLits —
// they must not be read as the count (bounded to before `set`). // 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") { 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!(count, None, "the count is None, not 18");
assert_eq!(overrides.len(), 1); assert_eq!(overrides.len(), 1);
} }
@@ -267,7 +273,14 @@ fn seed_populates_a_table_and_persists_rows() {
create_people(&db, &rt); create_people(&db, &rt);
let result = 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"); .expect("seed succeeds");
assert_eq!(result.produced, 7); 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}" "CSV should hold 7 generated rows:\n{csv}"
); );
// The generated `email` column produces address-shaped values. // 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 /// Parse a seeded table's CSV into per-column value lists (simple
/// comma-split — the values under test carry no commas/quotes). /// comma-split — the values under test carry no commas/quotes).
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) { fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = csv.lines().filter(|l| !l.trim().is_empty()); 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 header: Vec<String> = lines
let rows: Vec<Vec<String>> = .next()
lines.map(|l| l.split(',').map(str::to_string).collect()).collect(); .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) (header, rows)
} }
fn column_values(csv: &str, col: &str) -> Vec<String> { fn column_values(csv: &str, col: &str) -> Vec<String> {
let (header, rows) = csv_columns(csv); 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() rows.iter().map(|r| r[idx].clone()).collect()
} }
@@ -321,20 +346,36 @@ fn seed_year_and_choice_set_heuristics() {
)) ))
.expect("create Records"); .expect("create Records");
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into()))) rt.block_on(db.seed(
.expect("seed succeeds"); "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"); let csv = read_csv(&project, "Records").expect("Records CSV exists");
for y in column_values(&csv, "birth_year") { for y in column_values(&csv, "birth_year") {
let n: i32 = y.parse().expect("birth_year is an int"); 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") { for y in column_values(&csv, "published") {
let n: i32 = y.parse().expect("published is an int"); 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") { 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") { for s in column_values(&csv, "severity") {
assert!( assert!(
@@ -405,7 +446,14 @@ fn seed_count_defaults_to_twenty() {
create_people(&db, &rt); create_people(&db, &rt);
let result = 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"); .expect("seed succeeds");
assert_eq!(result.produced, 20, "omitted count defaults to 20"); assert_eq!(result.produced, 20, "omitted count defaults to 20");
let csv = read_csv(&project, "People").expect("People CSV exists"); 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(&db1, &rt);
create_people(&db2, &rt); create_people(&db2, &rt);
rt.block_on(db1.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into()))) rt.block_on(db1.seed(
.expect("seed run 1"); "People".into(),
rt.block_on(db2.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into()))) None,
.expect("seed run 2"); 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 csv1 = read_csv(&p1, "People").expect("csv 1");
let csv2 = read_csv(&p2, "People").expect("csv 2"); 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); create_users_and_orders(&db, &rt, true);
// 5 parents → serial ids 1..=5. // 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()))) rt.block_on(db.seed(
.expect("seed Users"); "Users".into(),
None,
Some(5),
Vec::new(),
Some(1),
Some("seed Users 5".into()),
))
.expect("seed Users");
let res = rt 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"); .expect("seed Orders");
assert_eq!(res.produced, 10, "every child row must insert (valid FK)"); 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. // Users is empty — no valid FK can be fabricated.
let err = rt 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"); .expect_err("seed must refuse an empty parent");
let msg = err.to_string(); 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(); let lower = msg.to_lowercase();
assert!( assert!(
lower.contains("no rows") || lower.contains("first"), lower.contains("no rows") || lower.contains("first"),
@@ -546,7 +632,14 @@ fn seed_refuses_a_not_null_blob_column() {
.expect("create Files"); .expect("create Files");
let err = rt 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"); .expect_err("seed must refuse a NOT NULL blob");
let msg = err.to_string(); let msg = err.to_string();
assert!( assert!(
@@ -573,7 +666,14 @@ fn seed_omits_a_nullable_blob_column() {
.expect("create Files"); .expect("create Files");
let res = rt 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"); .expect("seed succeeds despite the nullable blob");
assert_eq!(res.produced, 3); assert_eq!(res.produced, 3);
let csv = read_csv(&project, "Files").expect("Files CSV"); let csv = read_csv(&project, "Files").expect("Files CSV");
@@ -607,14 +707,25 @@ fn seed_keeps_unique_columns_distinct() {
.expect("create Tags"); .expect("create Tags");
let res = rt 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"); .expect("seed");
assert_eq!(res.produced, 8); assert_eq!(res.produced, 8);
let csv = read_csv(&project, "Tags").expect("Tags CSV"); let csv = read_csv(&project, "Tags").expect("Tags CSV");
let labels = nth_column_values(&csv, 1); let labels = nth_column_values(&csv, 1);
let distinct: std::collections::HashSet<&String> = labels.iter().collect(); 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] #[test]
@@ -636,7 +747,14 @@ fn seed_sequences_identifier_int_columns() {
.expect("create Items"); .expect("create Items");
let res = rt 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"); .expect("seed");
assert_eq!(res.produced, 5); assert_eq!(res.produced, 5);
@@ -646,7 +764,11 @@ fn seed_sequences_identifier_int_columns() {
.map(|s| s.parse().expect("code is an int")) .map(|s| s.parse().expect("code is an int"))
.collect(); .collect();
let distinct: std::collections::HashSet<i64> = codes.iter().copied().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] #[test]
@@ -667,14 +789,24 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
) )
.await .await
.expect("create parent"); .expect("create parent");
db.seed(t.into(), None, Some(2), Vec::new(), Some(1), Some(format!("seed {t} 2"))) db.seed(
.await t.into(),
.expect("seed parent"); 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. // Junction with a compound PK over its two FK columns.
db.create_table( db.create_table(
"J".to_string(), "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()], vec!["a".to_string(), "b".to_string()],
None, None,
) )
@@ -709,11 +841,21 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
// Requesting 10 caps at the 4 available distinct combinations. // Requesting 10 caps at the 4 available distinct combinations.
let res = db 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 .await
.expect("seed J"); .expect("seed J");
assert_eq!(res.produced, 4, "junction caps at available combos"); 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"); 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) .map(str::to_string)
.collect(); .collect();
let distinct: std::collections::HashSet<&String> = pairs.iter().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] #[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. // Every generated status must satisfy the CHECK, so all rows insert.
let res = rt 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"); .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"); let csv = read_csv(&project, "Tickets").expect("Tickets CSV");
for v in nth_column_values(&csv, 1) { for v in nth_column_values(&csv, 1) {
@@ -780,7 +936,14 @@ fn seed_advises_on_enum_ish_columns() {
.expect("create Tasks"); .expect("create Tasks");
let res = rt 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"); .expect("seed");
assert!( assert!(
res.advisory_columns.contains(&"status".to_string()), res.advisory_columns.contains(&"status".to_string()),
@@ -795,7 +958,14 @@ fn seed_refuses_an_excessive_count() {
let rt = rt(); let rt = rt();
create_people(&db, &rt); create_people(&db, &rt);
let err = 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"); .expect_err("an excessive count must be refused");
assert!( assert!(
err.to_string().to_lowercase().contains("maximum"), err.to_string().to_lowercase().contains("maximum"),
@@ -810,7 +980,14 @@ fn seed_preview_is_capped_but_count_is_full() {
create_people(&db, &rt); create_people(&db, &rt);
let res = 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"); .expect("seed");
assert_eq!(res.produced, 25, "the full count is produced"); assert_eq!(res.produced, 25, "the full count is produced");
assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows"); 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"); .expect("open db with undo");
let rt = rt(); let rt = rt();
create_people(&db, &rt); create_people(&db, &rt);
rt.block_on(db.seed("People".into(), None, Some(6), Vec::new(), Some(1), Some("seed People 6".into()))) rt.block_on(db.seed(
.expect("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); assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6);
// One undo removes the whole seed batch (ADR-0048 D15). // One undo removes the whole seed batch (ADR-0048 D15).
rt.block_on(db.undo()).unwrap().expect("undo applied"); rt.block_on(db.undo()).unwrap().expect("undo applied");
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c)); 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] #[test]
@@ -882,10 +1069,17 @@ fn seed_column_fill_is_one_undo_step() {
create_members(&db, &rt); create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed"); run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed");
// Fill `status` across all 5 rows with a constant, then undo once. // Fill `status` across all 5 rows with a constant, then undo once.
run_seed(&db, &rt, "seed Members.status set status = 'flagged' --seed 2") run_seed(
.expect("column-fill"); &db,
&rt,
"seed Members.status set status = 'flagged' --seed 2",
)
.expect("column-fill");
let before = named_column_values(&read_csv(&project, "Members").unwrap(), "status"); 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"); rt.block_on(db.undo()).unwrap().expect("undo applied");
let after = named_column_values(&read_csv(&project, "Members").unwrap(), "status"); 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"), after.iter().all(|s| s != "flagged"),
"one undo reverts the whole column-fill in a single step: {after:?}" "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] #[test]
@@ -930,10 +1128,23 @@ fn seed_rolls_back_atomically_on_a_constraint_failure() {
)) ))
.expect("create Bad"); .expect("create Bad");
let res = rt.block_on(db.seed("Bad".into(), None, Some(5), Vec::new(), Some(1), Some("seed Bad 5".into()))); let res = rt.block_on(db.seed(
assert!(res.is_err(), "seed must fail when generated rows violate the CHECK"); "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)); 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] #[test]
@@ -942,7 +1153,14 @@ fn seed_zero_is_a_no_op() {
let rt = rt(); let rt = rt();
create_people(&db, &rt); create_people(&db, &rt);
let res = 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"); .expect("seed 0 succeeds");
assert_eq!(res.produced, 0); assert_eq!(res.produced, 0);
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c)); 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"); .expect("create Widgets");
let res = rt 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"); .expect("seed");
assert!( assert!(
res.advisory_columns.contains(&"label".to_string()), 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 rt = rt();
let seed_one = |db: &Database| { let seed_one = |db: &Database| {
create_users_and_orders(db, &rt, true); 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()))) rt.block_on(db.seed(
.expect("seed users"); "Users".into(),
rt.block_on(db.seed("Orders".into(), None, Some(8), Vec::new(), Some(99), Some("seed Orders 8".into()))) None,
.expect("seed orders"); 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 (p1, db1, _d1) = open_project_db();
let (p2, db2, _d2) = 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, None,
)) ))
.expect("create Contacts"); .expect("create Contacts");
rt.block_on(db.seed("Contacts".into(), None, Some(5), Vec::new(), Some(42), Some("seed Contacts 5".into()))) rt.block_on(db.seed(
.expect("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 (p1, db1, _d1) = open_project_db();
let (p2, db2, _d2) = 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 csv1 = read_csv(&p1, "Contacts").unwrap();
let csv2 = read_csv(&p2, "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. // The shortid PK is populated with distinct 10-char base58 ids.
let codes = nth_column_values(&csv1, 0); let codes = nth_column_values(&csv1, 0);
assert_eq!(codes.len(), 5); assert_eq!(codes.len(), 5);
let distinct: std::collections::HashSet<&String> = codes.iter().collect(); 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 { for code in &codes {
assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}"); 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 csv = read_csv(&project, "Members").unwrap();
let statuses = named_column_values(&csv, "status"); let statuses = named_column_values(&csv, "status");
assert_eq!(statuses.len(), 6); 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] #[test]
@@ -1113,7 +1369,12 @@ fn seed_set_pick_list_draws_only_from_the_list() {
let (project, db, _d) = open_project_db(); let (project, db, _d) = open_project_db();
let rt = rt(); let rt = rt();
create_members(&db, &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 csv = read_csv(&project, "Members").unwrap();
let roles = named_column_values(&csv, "role"); let roles = named_column_values(&csv, "role");
assert!( 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"); run_seed(&db, &rt, "seed Members 5 set name as email --seed 3").expect("seed");
let csv = read_csv(&project, "Members").unwrap(); let csv = read_csv(&project, "Members").unwrap();
let names = named_column_values(&csv, "name"); 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] #[test]
@@ -1139,7 +1403,12 @@ fn seed_set_numeric_range_stays_within_bounds() {
let (project, db, _d) = open_project_db(); let (project, db, _d) = open_project_db();
let rt = rt(); let rt = rt();
create_members(&db, &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(); let csv = read_csv(&project, "Members").unwrap();
for a in named_column_values(&csv, "age") { for a in named_column_values(&csv, "age") {
let n: i64 = a.parse().unwrap_or_else(|_| panic!("age `{a}` not an int")); 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. // 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 err = run_seed(&db, &rt, "seed Members 3 set name between 1 and 10").unwrap_err();
let msg = format!("{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] #[test]
@@ -1221,8 +1493,12 @@ fn seed_column_fill_updates_existing_rows_without_adding() {
let before = data_row_count(&read_csv(&project, "Members").unwrap()); let before = data_row_count(&read_csv(&project, "Members").unwrap());
assert_eq!(before, 5); assert_eq!(before, 5);
let res = run_seed(&db, &rt, "seed Members.status set status in ('x', 'y') --seed 2") let res = run_seed(
.expect("column-fill"); &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"); assert_eq!(res.produced, 5, "column-fill touches the 5 existing rows");
let csv = read_csv(&project, "Members").unwrap(); let csv = read_csv(&project, "Members").unwrap();
assert_eq!(data_row_count(&csv), 5, "no new rows added"); 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); create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed"); run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed");
let err = run_seed(&db, &rt, "seed Members.id").unwrap_err(); 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] #[test]
@@ -1282,7 +1561,10 @@ fn seed_column_fill_rejects_a_row_count() {
Some("seed Members.status 5".into()), Some("seed Members.status 5".into()),
)) ))
.unwrap_err(); .unwrap_err();
assert!(format!("{err}").contains("no row count"), "count refused: {err}"); assert!(
format!("{err}").contains("no row count"),
"count refused: {err}"
);
} }
#[test] #[test]
@@ -1298,7 +1580,11 @@ fn seed_column_fill_fk_target_samples_the_parent() {
assert_eq!(res.produced, 8); assert_eq!(res.produced, 8);
let csv = read_csv(&project, "Orders").unwrap(); let csv = read_csv(&project, "Orders").unwrap();
let user_ids = named_column_values(&csv, "user_id"); 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] #[test]
@@ -1310,14 +1596,11 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
let rt = rt(); let rt = rt();
rt.block_on(db.create_table( rt.block_on(db.create_table(
"U".to_string(), "U".to_string(),
vec![ vec![ColumnSpec::new("id", Type::Serial), {
ColumnSpec::new("id", Type::Serial), let mut c = ColumnSpec::new("email", Type::Text);
{ c.unique = true;
let mut c = ColumnSpec::new("email", Type::Text); c
c.unique = true; }],
c
},
],
vec!["id".to_string()], vec!["id".to_string()],
None, None,
)) ))
@@ -1330,7 +1613,10 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
); );
// A short pick-list (< count) is likewise refused... // 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(); 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. // ...but a pick-list with enough distinct values succeeds.
let ok = run_seed( let ok = run_seed(
&db, &db,
@@ -1354,14 +1640,11 @@ fn seed_column_fill_fixed_on_unique_column_is_a_friendly_error() {
let rt = rt(); let rt = rt();
rt.block_on(db.create_table( rt.block_on(db.create_table(
"U".to_string(), "U".to_string(),
vec![ vec![ColumnSpec::new("id", Type::Serial), {
ColumnSpec::new("id", Type::Serial), let mut c = ColumnSpec::new("email", Type::Text);
{ c.unique = true;
let mut c = ColumnSpec::new("email", Type::Text); c
c.unique = true; }],
c
},
],
vec!["id".to_string()], vec!["id".to_string()],
None, None,
)) ))
+43 -27
View File
@@ -17,7 +17,7 @@ use rdbms_playground::action::Action;
use rdbms_playground::app::App; use rdbms_playground::app::App;
use rdbms_playground::db::Database; use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ 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::event::AppEvent;
use rdbms_playground::mode::Mode; use rdbms_playground::mode::Mode;
@@ -108,8 +108,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with 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 // Name, both endpoints, and the non-default ON DELETE CASCADE
// (ON UPDATE NO ACTION is the default and is omitted). // (ON UPDATE NO ACTION is the default and is omitted).
assert_eq!( assert_eq!(
lines[1], lines[1], " orders_customer: Customers.id → Orders.customer_id on delete cascade",
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
"relationship summary line: {lines:?}", "relationship summary line: {lines:?}",
); );
} }
@@ -222,7 +220,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
let rt = rt(); let rt = rt();
// No schema seeded — every kind is empty. // No schema seeded — every kind is empty.
assert_eq!( 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()], vec!["No tables in this project yet.".to_string()],
); );
assert_eq!( assert_eq!(
@@ -231,7 +230,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
vec!["No relationships in this project yet.".to_string()], vec!["No relationships in this project yet.".to_string()],
); );
assert_eq!( 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()], vec!["No indexes in this project yet.".to_string()],
); );
} }
@@ -246,7 +246,10 @@ fn show_one_relationship_renders_detail_block() {
let rt = rt(); let rt = rt();
rt.block_on(seed_schema(&db)); rt.block_on(seed_schema(&db));
let lines = rt 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"); .expect("show relationship");
assert_eq!(lines[0], "Relationship `orders_customer`:"); assert_eq!(lines[0], "Relationship `orders_customer`:");
assert_eq!(lines[1], " Customers.id → Orders.customer_id"); assert_eq!(lines[1], " Customers.id → Orders.customer_id");
@@ -262,7 +265,10 @@ fn show_one_index_renders_detail_block() {
let rt = rt(); let rt = rt();
rt.block_on(seed_schema(&db)); rt.block_on(seed_schema(&db));
let lines = rt 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"); .expect("show index");
assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:"); assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:");
assert!( 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] #[test]
@@ -337,10 +346,11 @@ fn app_renders_show_list_lines_as_system_output() {
// Feed the success event directly so the test stays // Feed the success event directly so the test stays
// self-contained (the worker round-trip is covered above). // self-contained (the worker round-trip is covered above).
let mut app = App::new(); let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo( app.output
"show tables", .push_back(rdbms_playground::app::OutputLine::echo(
Mode::Simple, "show tables",
)); Mode::Simple,
));
app.update(AppEvent::DslShowListSucceeded { app.update(AppEvent::DslShowListSucceeded {
command: Command::ShowList { command: Command::ShowList {
kind: ShowListKind::Tables, kind: ShowListKind::Tables,
@@ -403,10 +413,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
.expect("found"); .expect("found");
let mut app = App::new(); let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo( app.output
"show relationship orders_customer", .push_back(rdbms_playground::app::OutputLine::echo(
Mode::Simple, "show relationship orders_customer",
)); Mode::Simple,
));
app.update(AppEvent::DslShowRelationshipSucceeded { app.update(AppEvent::DslShowRelationshipSucceeded {
command: Command::ShowList { command: Command::ShowList {
kind: ShowListKind::Relationships, 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. // Both tables, box-drawing, the connector arrow, the actions line.
assert!(text.contains("Orders"), "child box: {text}"); assert!(text.contains("Orders"), "child box: {text}");
assert!(text.contains("Customers"), "parent 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('▶'), "connector arrow: {text}");
assert!(text.contains("on delete cascade"), "actions: {text}"); assert!(text.contains("on delete cascade"), "actions: {text}");
// The diagram lines are styled (per-span runs), not plain system. // 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] #[test]
fn app_show_relationship_not_found_shows_friendly_line() { fn app_show_relationship_not_found_shows_friendly_line() {
let mut app = App::new(); let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo( app.output
"show relationship nope", .push_back(rdbms_playground::app::OutputLine::echo(
Mode::Simple, "show relationship nope",
)); Mode::Simple,
));
app.update(AppEvent::DslShowRelationshipSucceeded { app.update(AppEvent::DslShowRelationshipSucceeded {
command: Command::ShowList { command: Command::ShowList {
kind: ShowListKind::Relationships, kind: ShowListKind::Relationships,
@@ -466,10 +481,11 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
.expect("describe Orders"); .expect("describe Orders");
let mut app = App::new(); let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo( app.output
"show table Orders", .push_back(rdbms_playground::app::OutputLine::echo(
Mode::Simple, "show table Orders",
)); Mode::Simple,
));
app.update(AppEvent::DslSucceeded { app.update(AppEvent::DslSucceeded {
command: Command::ShowTable { command: Command::ShowTable {
name: "Orders".to_string(), name: "Orders".to_string(),
+200 -56
View File
@@ -30,8 +30,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) { fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let db = Database::open_with_persistence( let db = Database::open_with_persistence(
project.db_path(), project.db_path(),
Persistence::new(project.path().to_path_buf()), Persistence::new(project.path().to_path_buf()),
@@ -42,8 +41,7 @@ fn open() -> (project::Project, Database, tempfile::TempDir) {
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) { fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let db = Database::open_with_persistence_and_undo( let db = Database::open_with_persistence_and_undo(
project.db_path(), project.db_path(),
Persistence::new(project.path().to_path_buf()), Persistence::new(project.path().to_path_buf()),
@@ -159,7 +157,10 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
// Final schema: id, label (renamed from v), qty; `note` added then // Final schema: id, label (renamed from v), qty; `note` added then
// dropped. // dropped.
let cols = column_names(&db, &r); let cols = column_names(&db, &r);
assert_eq!(cols, vec!["id".to_string(), "label".to_string(), "qty".to_string()]); assert_eq!(
cols,
vec!["id".to_string(), "label".to_string(), "qty".to_string()]
);
// The DEFAULT backfilled the pre-existing row to qty = 0. // The DEFAULT backfilled the pre-existing row to qty = 0.
let rows = r let rows = r
@@ -168,14 +169,21 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
.rows; .rows;
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
// qty is the third column; the rebuild backfilled the default. // qty is the third column; the rebuild backfilled the default.
assert_eq!(rows[0][2].as_deref(), Some("0"), "DEFAULT 0 backfilled the existing row"); assert_eq!(
rows[0][2].as_deref(),
Some("0"),
"DEFAULT 0 backfilled the existing row"
);
// The CHECK (qty >= 0) is enforced: a negative qty is refused. // The CHECK (qty >= 0) is enforced: a negative qty is refused.
assert!( assert!(
r.block_on(db.insert( r.block_on(db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "qty".to_string()]), Some(vec!["id".to_string(), "qty".to_string()]),
vec![Value::Number("2".to_string()), Value::Number("-1".to_string())], vec![
Value::Number("2".to_string()),
Value::Number("-1".to_string())
],
Some("insert".to_string()), Some("insert".to_string()),
)) ))
.is_err(), .is_err(),
@@ -185,7 +193,10 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
r.block_on(db.insert( r.block_on(db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "qty".to_string()]), Some(vec!["id".to_string(), "qty".to_string()]),
vec![Value::Number("3".to_string()), Value::Number("7".to_string())], vec![
Value::Number("3".to_string()),
Value::Number("7".to_string()),
],
Some("insert".to_string()), Some("insert".to_string()),
)) ))
.expect("qty = 7 satisfies the CHECK"); .expect("qty = 7 satisfies the CHECK");
@@ -214,7 +225,10 @@ fn e2e_alter_add_column_survives_rebuild() {
r.block_on(db.insert( r.block_on(db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "qty".to_string()]), Some(vec!["id".to_string(), "qty".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("-5".to_string())], vec![
Value::Number("1".to_string()),
Value::Number("-5".to_string())
],
Some("insert".to_string()), Some("insert".to_string()),
)) ))
.is_err(), .is_err(),
@@ -257,9 +271,17 @@ fn e2e_alter_column_type_clean_and_lossy_convert() {
.rows; .rows;
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
// v (col 1): lossy real→int performed → 3.7 stored as 3. // v (col 1): lossy real→int performed → 3.7 stored as 3.
assert_eq!(rows[0][1].as_deref(), Some("3"), "lossy real→int performed (3.7→3)"); assert_eq!(
rows[0][1].as_deref(),
Some("3"),
"lossy real→int performed (3.7→3)"
);
// w (col 2): clean int→text stringified → "42". // w (col 2): clean int→text stringified → "42".
assert_eq!(rows[0][2].as_deref(), Some("42"), "clean int→text stringified"); assert_eq!(
rows[0][2].as_deref(),
Some("42"),
"clean int→text stringified"
);
// The columns now carry the new user-facing types (round-tripped // The columns now carry the new user-facing types (round-tripped
// through the metadata). // through the metadata).
@@ -290,12 +312,20 @@ fn e2e_alter_column_type_int_to_serial_is_allowed() {
} }
other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"), other => panic!("expected ReplayCompleted, got {other:?} (events: {events:?})"),
} }
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column"); assert_eq!(
col_type(&db, &r, "n"),
Some(Type::Serial),
"int→serial converted the column"
);
let rows = r let rows = r
.block_on(db.query_data("T".to_string(), None, None)) .block_on(db.query_data("T".to_string(), None, None))
.expect("query") .expect("query")
.rows; .rows;
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved"); assert_eq!(
rows[0][1].as_deref(),
Some("100"),
"the existing value is preserved"
);
} }
#[test] #[test]
@@ -368,11 +398,19 @@ fn e2e_alter_column_type_survives_rebuild() {
) )
.expect("write script"); .expect("write script");
r.block_on(run_replay(&db, project.path(), "conv.commands")); r.block_on(run_replay(&db, project.path(), "conv.commands"));
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "converted before rebuild"); assert_eq!(
col_type(&db, &r, "v"),
Some(Type::Int),
"converted before rebuild"
);
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild"); .expect("rebuild");
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the converted type survives rebuild"); assert_eq!(
col_type(&db, &r, "v"),
Some(Type::Int),
"the converted type survives rebuild"
);
} }
#[test] #[test]
@@ -393,14 +431,22 @@ fn e2e_alter_column_type_is_one_undo_step() {
) )
.expect("write script"); .expect("write script");
r.block_on(run_replay(&db, project.path(), "conv.commands")); r.block_on(run_replay(&db, project.path(), "conv.commands"));
assert_eq!(col_type(&db, &r, "v"), Some(Type::Int), "the SQL ALTER COLUMN TYPE converted v"); assert_eq!(
col_type(&db, &r, "v"),
Some(Type::Int),
"the SQL ALTER COLUMN TYPE converted v"
);
// A single undo reverts the whole conversion. // A single undo reverts the whole conversion.
assert!( assert!(
r.block_on(db.undo()).expect("undo").is_some(), r.block_on(db.undo()).expect("undo").is_some(),
"the conversion was one undo step" "the conversion was one undo step"
); );
assert_eq!(col_type(&db, &r, "v"), Some(Type::Real), "one undo restored the pre-conversion type"); assert_eq!(
col_type(&db, &r, "v"),
Some(Type::Real),
"one undo restored the pre-conversion type"
);
} }
// --- 4g: ADD/DROP constraint + ADD foreign key (ADR-0035 §4g) ----------- // --- 4g: ADD/DROP constraint + ADD foreign key (ADR-0035 §4g) -----------
@@ -410,7 +456,10 @@ fn insert_t_qty_ok(db: &Database, r: &tokio::runtime::Runtime, id: i64, qty: i64
r.block_on(db.insert( r.block_on(db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "qty".to_string()]), Some(vec!["id".to_string(), "qty".to_string()]),
vec![Value::Number(id.to_string()), Value::Number(qty.to_string())], vec![
Value::Number(id.to_string()),
Value::Number(qty.to_string()),
],
Some("insert".to_string()), Some("insert".to_string()),
)) ))
.is_ok() .is_ok()
@@ -436,14 +485,23 @@ fn e2e_add_named_check_enforced_and_survives_rebuild_with_its_name() {
"events: {events:?}" "events: {events:?}"
); );
// Enforced: qty = -1 refused, qty = 5 accepted. // Enforced: qty = -1 refused, qty = 5 accepted.
assert!(!insert_t_qty_ok(&db, &r, 1, -1), "the CHECK rejects qty = -1"); assert!(
assert!(insert_t_qty_ok(&db, &r, 2, 5), "qty = 5 satisfies the CHECK"); !insert_t_qty_ok(&db, &r, 1, -1),
"the CHECK rejects qty = -1"
);
assert!(
insert_t_qty_ok(&db, &r, 2, 5),
"qty = 5 satisfies the CHECK"
);
// Rebuild from text, then DROP CONSTRAINT by name must still work → // Rebuild from text, then DROP CONSTRAINT by name must still work →
// the name survived the round-trip. // the name survived the round-trip.
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild"); .expect("rebuild");
assert!(!insert_t_qty_ok(&db, &r, 3, -2), "the CHECK is intact after rebuild"); assert!(
!insert_t_qty_ok(&db, &r, 3, -2),
"the CHECK is intact after rebuild"
);
std::fs::write( std::fs::write(
project.path().join("drop.commands"), project.path().join("drop.commands"),
"alter table T drop constraint qty_positive\n", "alter table T drop constraint qty_positive\n",
@@ -502,13 +560,19 @@ fn e2e_add_composite_unique_enforced_and_survives_rebuild() {
)) ))
.is_ok() .is_ok()
}; };
assert!(!dup_ok(2, 1, 2), "the composite UNIQUE rejects the duplicate (1, 2)"); assert!(
!dup_ok(2, 1, 2),
"the composite UNIQUE rejects the duplicate (1, 2)"
);
assert!(dup_ok(3, 1, 3), "(1, 3) is distinct and accepted"); assert!(dup_ok(3, 1, 3), "(1, 3) is distinct and accepted");
// Survives rebuild (the unique_constraints yaml path). // Survives rebuild (the unique_constraints yaml path).
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.expect("rebuild"); .expect("rebuild");
assert!(!dup_ok(4, 1, 2), "the composite UNIQUE is intact after rebuild"); assert!(
!dup_ok(4, 1, 2),
"the composite UNIQUE is intact after rebuild"
);
} }
#[test] #[test]
@@ -559,7 +623,10 @@ fn e2e_drop_composite_unique_by_derived_name() {
.is_ok() .is_ok()
}; };
assert!(dup_ok(1, 1, 2), "first (1, 2) accepted"); assert!(dup_ok(1, 1, 2), "first (1, 2) accepted");
assert!(!dup_ok(2, 1, 2), "duplicate (1, 2) rejected while the UNIQUE stands"); assert!(
!dup_ok(2, 1, 2),
"duplicate (1, 2) rejected while the UNIQUE stands"
);
// Drop the UNIQUE by its derived name through the existing DROP // Drop the UNIQUE by its derived name through the existing DROP
// CONSTRAINT grammar. // CONSTRAINT grammar.
@@ -572,7 +639,10 @@ fn e2e_drop_composite_unique_by_derived_name() {
// The UNIQUE no longer enforces: the previously-rejected duplicate is // The UNIQUE no longer enforces: the previously-rejected duplicate is
// now accepted. // now accepted.
assert!(dup_ok(3, 1, 2), "duplicate (1, 2) accepted after the UNIQUE was dropped"); assert!(
dup_ok(3, 1, 2),
"duplicate (1, 2) accepted after the UNIQUE was dropped"
);
// And it stays gone across a rebuild from text. // And it stays gone across a rebuild from text.
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))) r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
@@ -676,8 +746,14 @@ fn e2e_add_foreign_key_missing_child_column_refuses_without_dsl_flag() {
let AppEvent::ReplayFailed { error, .. } = events.last().expect("an event") else { let AppEvent::ReplayFailed { error, .. } = events.last().expect("an event") else {
panic!("expected ReplayFailed; events: {events:?}"); panic!("expected ReplayFailed; events: {events:?}");
}; };
assert!(!error.contains("--create-fk"), "no DSL flag in the SQL refusal; got: {error}"); assert!(
assert!(error.contains("pid"), "names the missing column; got: {error}"); !error.contains("--create-fk"),
"no DSL flag in the SQL refusal; got: {error}"
);
assert!(
error.contains("pid"),
"names the missing column; got: {error}"
);
assert!( assert!(
error.to_lowercase().contains("add it first") error.to_lowercase().contains("add it first")
|| error.to_lowercase().contains("does not exist"), || error.to_lowercase().contains("does not exist"),
@@ -709,12 +785,21 @@ fn e2e_add_foreign_key_creates_an_enforced_relationship() {
r.block_on(db.insert( r.block_on(db.insert(
"C".to_string(), "C".to_string(),
Some(vec!["cid".to_string(), "pid".to_string()]), Some(vec!["cid".to_string(), "pid".to_string()]),
vec![Value::Number(cid.to_string()), Value::Number(pid.to_string())], vec![
Value::Number(cid.to_string()),
Value::Number(pid.to_string()),
],
Some("insert".to_string()), Some("insert".to_string()),
)) ))
}; };
assert!(insert_c(10, 1).is_ok(), "a child referencing parent id=1 is accepted"); assert!(
assert!(insert_c(11, 999).is_err(), "a child referencing a missing parent is rejected"); insert_c(10, 1).is_ok(),
"a child referencing parent id=1 is accepted"
);
assert!(
insert_c(11, 999).is_err(),
"a child referencing a missing parent is rejected"
);
} }
#[test] #[test]
@@ -740,7 +825,10 @@ fn e2e_drop_constraint_removes_a_named_foreign_key() {
r.block_on(db.insert( r.block_on(db.insert(
"C".to_string(), "C".to_string(),
Some(vec!["cid".to_string(), "pid".to_string()]), Some(vec!["cid".to_string(), "pid".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("999".to_string())], vec![
Value::Number("1".to_string()),
Value::Number("999".to_string())
],
Some("insert".to_string()), Some("insert".to_string()),
)) ))
.is_ok(), .is_ok(),
@@ -798,7 +886,10 @@ fn e2e_add_constraint_is_one_undo_step() {
"the ADD CONSTRAINT was one undo step" "the ADD CONSTRAINT was one undo step"
); );
// After undo the CHECK is gone: qty = -1 is accepted. // After undo the CHECK is gone: qty = -1 is accepted.
assert!(insert_t_qty_ok(&db, &r, 3, -1), "one undo removed the CHECK"); assert!(
insert_t_qty_ok(&db, &r, 3, -1),
"one undo removed the CHECK"
);
} }
#[test] #[test]
@@ -819,7 +910,10 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
.expect("db"); .expect("db");
r.block_on(db.sql_create_table( r.block_on(db.sql_create_table(
"T".to_string(), "T".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()], vec!["id".to_string()],
vec![], vec![],
vec![], vec![],
@@ -846,7 +940,8 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
Persistence::new(project.path().to_path_buf()), Persistence::new(project.path().to_path_buf()),
) )
.unwrap(); .unwrap();
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None)).expect("rebuild"); r.block_on(db.rebuild_from_text(project.path().to_path_buf(), None))
.expect("rebuild");
// The named CHECK metadata survived: DROP CONSTRAINT by name resolves. // The named CHECK metadata survived: DROP CONSTRAINT by name resolves.
r.block_on(db.alter_drop_constraint( r.block_on(db.alter_drop_constraint(
@@ -878,7 +973,9 @@ fn e2e_describe_shows_table_level_constraints() {
"events: {events:?}" "events: {events:?}"
); );
let desc = r.block_on(db.describe_table("T".to_string())).expect("describe"); let desc = r
.block_on(db.describe_table("T".to_string()))
.expect("describe");
assert_eq!( assert_eq!(
desc.unique_constraints, desc.unique_constraints,
vec![vec!["a".to_string(), "b".to_string()]], vec![vec!["a".to_string(), "b".to_string()]],
@@ -890,7 +987,9 @@ fn e2e_describe_shows_table_level_constraints() {
.map(|c| (c.name.clone(), c.expr.clone())) .map(|c| (c.name.clone(), c.expr.clone()))
.collect(); .collect();
assert!( assert!(
checks.iter().any(|(n, e)| n.is_none() && e.contains("a < b")), checks
.iter()
.any(|(n, e)| n.is_none() && e.contains("a < b")),
"unnamed table CHECK surfaced: {checks:?}" "unnamed table CHECK surfaced: {checks:?}"
); );
assert!( assert!(
@@ -972,8 +1071,14 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
tables.contains(&"Purchases".to_string()) && !tables.contains(&"Orders".to_string()), tables.contains(&"Purchases".to_string()) && !tables.contains(&"Orders".to_string()),
"the table is now Purchases, not Orders: {tables:?}" "the table is now Purchases, not Orders: {tables:?}"
); );
assert!(csv_path(&project, "Purchases").exists(), "data/Purchases.csv written"); assert!(
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed"); csv_path(&project, "Purchases").exists(),
"data/Purchases.csv written"
);
assert!(
!csv_path(&project, "Orders").exists(),
"data/Orders.csv removed"
);
let rows = r let rows = r
.block_on(db.query_data("Purchases".to_string(), None, None)) .block_on(db.query_data("Purchases".to_string(), None, None))
@@ -1052,7 +1157,10 @@ fn e2e_rename_table_with_table_qualified_check_survives_fresh_rebuild() {
], ],
Some("i".into()), Some("i".into()),
)); ));
assert!(bad_after.is_err(), "the rewritten CHECK enforces after a fresh rebuild"); assert!(
bad_after.is_err(),
"the rewritten CHECK enforces after a fresh rebuild"
);
} }
#[test] #[test]
@@ -1077,7 +1185,9 @@ fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() {
); );
// The child's outbound relationship now points at the new parent name. // The child's outbound relationship now points at the new parent name.
let c = r.block_on(db.describe_table("C".to_string())).expect("describe C"); let c = r
.block_on(db.describe_table("C".to_string()))
.expect("describe C");
assert_eq!(c.outbound_relationships.len(), 1); assert_eq!(c.outbound_relationships.len(), 1);
assert_eq!(c.outbound_relationships[0].other_table, "Parent"); assert_eq!(c.outbound_relationships[0].other_table, "Parent");
@@ -1129,7 +1239,9 @@ fn e2e_rename_fk_child_updates_metadata_and_still_enforces() {
); );
// The parent's inbound relationship now names the renamed child. // The parent's inbound relationship now names the renamed child.
let p = r.block_on(db.describe_table("P".to_string())).expect("describe P"); let p = r
.block_on(db.describe_table("P".to_string()))
.expect("describe P");
assert_eq!(p.inbound_relationships.len(), 1); assert_eq!(p.inbound_relationships.len(), 1);
assert_eq!(p.inbound_relationships[0].other_table, "Child"); assert_eq!(p.inbound_relationships[0].other_table, "Child");
@@ -1168,7 +1280,9 @@ fn e2e_rename_self_referential_table_updates_both_ends() {
); );
// Both ends of the self-reference now name `Tree`. // Both ends of the self-reference now name `Tree`.
let t = r.block_on(db.describe_table("Tree".to_string())).expect("describe Tree"); let t = r
.block_on(db.describe_table("Tree".to_string()))
.expect("describe Tree");
assert_eq!(t.outbound_relationships[0].other_table, "Tree"); assert_eq!(t.outbound_relationships[0].other_table, "Tree");
assert_eq!(t.inbound_relationships[0].other_table, "Tree"); assert_eq!(t.inbound_relationships[0].other_table, "Tree");
@@ -1216,7 +1330,9 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
"events: {events:?}" "events: {events:?}"
); );
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users"); let u = r
.block_on(db.describe_table("Users".to_string()))
.expect("describe Users");
assert_eq!(u.indexes.len(), 1, "the index followed the rename"); assert_eq!(u.indexes.len(), 1, "the index followed the rename");
assert_eq!( assert_eq!(
u.indexes[0].name, "T_email_idx", u.indexes[0].name, "T_email_idx",
@@ -1226,7 +1342,9 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
// Survives a fresh rebuild (recreated from IndexSchema on table Users). // Survives a fresh rebuild (recreated from IndexSchema on table Users).
let db = fresh_rebuild(db, &project, &r); let db = fresh_rebuild(db, &project, &r);
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users"); let u = r
.block_on(db.describe_table("Users".to_string()))
.expect("describe Users");
assert_eq!(u.indexes.len(), 1); assert_eq!(u.indexes.len(), 1);
assert_eq!(u.indexes[0].name, "T_email_idx"); assert_eq!(u.indexes[0].name, "T_email_idx");
} }
@@ -1248,14 +1366,20 @@ fn e2e_rename_table_is_one_undo_step() {
assert!(table_names(&db, &r).contains(&"Purchases".to_string())); assert!(table_names(&db, &r).contains(&"Purchases".to_string()));
// One undo reverts the rename. // One undo reverts the rename.
assert!(r.block_on(db.undo()).expect("undo").is_some(), "rename was one undo step"); assert!(
r.block_on(db.undo()).expect("undo").is_some(),
"rename was one undo step"
);
let tables = table_names(&db, &r); let tables = table_names(&db, &r);
assert!( assert!(
tables.contains(&"Orders".to_string()) && !tables.contains(&"Purchases".to_string()), tables.contains(&"Orders".to_string()) && !tables.contains(&"Purchases".to_string()),
"undo restored the old table name: {tables:?}" "undo restored the old table name: {tables:?}"
); );
assert_eq!( assert_eq!(
r.block_on(db.query_data("Orders".to_string(), None, None)).expect("query").rows.len(), r.block_on(db.query_data("Orders".to_string(), None, None))
.expect("query")
.rows
.len(),
1, 1,
"the row is back under the old name" "the row is back under the old name"
); );
@@ -1286,19 +1410,23 @@ fn e2e_rename_table_refusals() {
r.block_on(run_replay(&db, project.path(), "setup.commands")); r.block_on(run_replay(&db, project.path(), "setup.commands"));
assert!( assert!(
r.block_on(db.rename_table("T".into(), "X".into(), Some("rn".into()))).is_err(), r.block_on(db.rename_table("T".into(), "X".into(), Some("rn".into())))
.is_err(),
"rename to an existing other table is refused" "rename to an existing other table is refused"
); );
assert!( assert!(
r.block_on(db.rename_table("T".into(), "T".into(), Some("rn".into()))).is_err(), r.block_on(db.rename_table("T".into(), "T".into(), Some("rn".into())))
.is_err(),
"rename to the same name is refused" "rename to the same name is refused"
); );
assert!( assert!(
r.block_on(db.rename_table("Ghost".into(), "G".into(), Some("rn".into()))).is_err(), r.block_on(db.rename_table("Ghost".into(), "G".into(), Some("rn".into())))
.is_err(),
"rename of a non-existent table is refused" "rename of a non-existent table is refused"
); );
assert!( assert!(
r.block_on(db.rename_table("T".into(), "__rdbms_evil".into(), Some("rn".into()))).is_err(), r.block_on(db.rename_table("T".into(), "__rdbms_evil".into(), Some("rn".into())))
.is_err(),
"rename to an internal table name is refused at the executor" "rename to an internal table name is refused at the executor"
); );
@@ -1315,7 +1443,8 @@ fn e2e_rename_table_refusals() {
); );
} }
assert!( assert!(
r.block_on(db.rename_table("T".into(), "x".into(), Some("rn".into()))).is_err(), r.block_on(db.rename_table("T".into(), "x".into(), Some("rn".into())))
.is_err(),
"rename to a name colliding case-insensitively with another table (X) is refused" "rename to a name colliding case-insensitively with another table (X) is refused"
); );
@@ -1348,7 +1477,10 @@ fn e2e_alter_column_set_not_null_enforced() {
.expect("write"); .expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands")); let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!( assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 4, .. })
),
"set not null on a clean column succeeds; events: {events:?}" "set not null on a clean column succeeds; events: {events:?}"
); );
assert!( assert!(
@@ -1390,7 +1522,10 @@ fn e2e_alter_column_drop_not_null_allows_nulls() {
.expect("write"); .expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands")); let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!( assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 4, .. })
),
"events: {events:?}" "events: {events:?}"
); );
r.block_on(db.insert( r.block_on(db.insert(
@@ -1416,7 +1551,10 @@ fn e2e_alter_column_set_default_applies() {
.expect("write"); .expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands")); let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!( assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 3, .. })), matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 3, .. })
),
"events: {events:?}" "events: {events:?}"
); );
r.block_on(db.insert( r.block_on(db.insert(
@@ -1462,7 +1600,10 @@ fn e2e_alter_column_drop_default_removes_it() {
.expect("write"); .expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands")); let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!( assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 4, .. })
),
"events: {events:?}" "events: {events:?}"
); );
r.block_on(db.insert( r.block_on(db.insert(
@@ -1499,7 +1640,10 @@ fn e2e_alter_column_set_data_type_converts() {
.expect("write"); .expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands")); let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!( assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })), matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 4, .. })
),
"events: {events:?}" "events: {events:?}"
); );
assert_eq!( assert_eq!(
+48 -14
View File
@@ -21,8 +21,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo) let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -33,7 +32,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
fn make_t(db: &Database, r: &tokio::runtime::Runtime) { fn make_t(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table( r.block_on(db.sql_create_table(
"T".to_string(), "T".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)], vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("email", Type::Text),
],
vec!["id".to_string()], vec!["id".to_string()],
vec![], vec![],
vec![], vec![],
@@ -48,8 +50,13 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str)
r.block_on(db.insert( r.block_on(db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "email".to_string()]), Some(vec!["id".to_string(), "email".to_string()]),
vec![Value::Number(id.to_string()), Value::Text(email.to_string())], vec![
Some(format!("insert into T (id, email) values ({id}, '{email}')")), Value::Number(id.to_string()),
Value::Text(email.to_string()),
],
Some(format!(
"insert into T (id, email) values ({id}, '{email}')"
)),
)) ))
.is_ok() .is_ok()
} }
@@ -79,7 +86,10 @@ fn create_plain_index() {
)) ))
.expect("create index"); .expect("create index");
assert!(matches!(out, CreateIndexOutcome::Created(_))); assert!(matches!(out, CreateIndexOutcome::Created(_)));
assert_eq!(index(&db, &r, "ix"), Some((vec!["email".to_string()], false))); assert_eq!(
index(&db, &r, "ix"),
Some((vec!["email".to_string()], false))
);
} }
#[test] #[test]
@@ -97,14 +107,20 @@ fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() {
)) ))
.expect("create unique index"); .expect("create unique index");
// Reported as unique. // Reported as unique.
assert_eq!(index(&db, &r, "ux"), Some((vec!["email".to_string()], true))); assert_eq!(
index(&db, &r, "ux"),
Some((vec!["email".to_string()], true))
);
// Persisted to project.yaml as a unique index. // Persisted to project.yaml as a unique index.
let yaml = std::fs::read_to_string(p.path().join("project.yaml")).expect("read project.yaml"); let yaml = std::fs::read_to_string(p.path().join("project.yaml")).expect("read project.yaml");
assert!(yaml.contains("unique: true"), "project.yaml:\n{yaml}"); assert!(yaml.contains("unique: true"), "project.yaml:\n{yaml}");
// Uniqueness is enforced by the engine. // Uniqueness is enforced by the engine.
assert!(insert_row(&db, &r, 1, "a@x")); assert!(insert_row(&db, &r, 1, "a@x"));
assert!(!insert_row(&db, &r, 2, "a@x"), "duplicate email refused by the unique index"); assert!(
!insert_row(&db, &r, 2, "a@x"),
"duplicate email refused by the unique index"
);
// Rebuild from the text artifacts: the index comes back UNIQUE // Rebuild from the text artifacts: the index comes back UNIQUE
// (the rebuild re-emits CREATE UNIQUE INDEX), not demoted to plain. // (the rebuild re-emits CREATE UNIQUE INDEX), not demoted to plain.
@@ -116,7 +132,10 @@ fn create_unique_index_round_trips_and_survives_rebuild_and_enforces() {
"the unique flag survived rebuild" "the unique flag survived rebuild"
); );
// Still enforced after rebuild. // Still enforced after rebuild.
assert!(!insert_row(&db, &r, 3, "a@x"), "uniqueness enforced after rebuild too"); assert!(
!insert_row(&db, &r, 3, "a@x"),
"uniqueness enforced after rebuild too"
);
} }
#[test] #[test]
@@ -264,7 +283,10 @@ fn plain_duplicate_name_errors() {
false, false,
Some("create index ix on T (id)".to_string()), Some("create index ix on T (id)".to_string()),
)); ));
assert!(res.is_err(), "duplicate index name without IF NOT EXISTS errors"); assert!(
res.is_err(),
"duplicate index name without IF NOT EXISTS errors"
);
} }
#[test] #[test]
@@ -307,7 +329,10 @@ fn plain_and_unique_over_the_same_columns_are_not_duplicates() {
false, false,
Some("create index ix_plain2 on T (email)".to_string()), Some("create index ix_plain2 on T (email)".to_string()),
)); ));
assert!(res.is_err(), "a second plain index over the same columns is redundant"); assert!(
res.is_err(),
"a second plain index over the same columns is redundant"
);
} }
#[test] #[test]
@@ -328,7 +353,10 @@ fn create_index_on_an_internal_table_is_refused_on_both_surfaces() {
false, false,
Some("create index bad on __rdbms_playground_columns (table_name)".to_string()), Some("create index bad on __rdbms_playground_columns (table_name)".to_string()),
)); ));
assert!(sql.is_err(), "SQL CREATE INDEX on an internal table is refused"); assert!(
sql.is_err(),
"SQL CREATE INDEX on an internal table is refused"
);
// Simple `add index` on an internal table → error (same guard). // Simple `add index` on an internal table → error (same guard).
let dsl = r.block_on(db.add_index( let dsl = r.block_on(db.add_index(
Some("bad2".to_string()), Some("bad2".to_string()),
@@ -336,7 +364,10 @@ fn create_index_on_an_internal_table_is_refused_on_both_surfaces() {
vec!["table_name".to_string()], vec!["table_name".to_string()],
Some("add index as bad2 on __rdbms_playground_columns (table_name)".to_string()), Some("add index as bad2 on __rdbms_playground_columns (table_name)".to_string()),
)); ));
assert!(dsl.is_err(), "simple add index on an internal table is refused"); assert!(
dsl.is_err(),
"simple add index on an internal table is refused"
);
} }
#[test] #[test]
@@ -355,6 +386,9 @@ fn create_index_is_one_undo_step() {
.expect("create index"); .expect("create index");
assert!(index(&db, &r, "ix").is_some()); assert!(index(&db, &r, "ix").is_some());
// One undo removes the index. // One undo removes the index.
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the create was one undo step"); assert!(
r.block_on(db.undo()).expect("undo").is_some(),
"the create was one undo step"
);
assert!(index(&db, &r, "ix").is_none(), "undo removed the index"); assert!(index(&db, &r, "ix").is_none(), "undo removed the index");
} }
File diff suppressed because it is too large Load Diff
+261 -64
View File
@@ -34,8 +34,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -83,9 +82,11 @@ fn run_delete(
input: &str, input: &str,
) -> Result<DeleteResult, DbError> { ) -> Result<DeleteResult, DbError> {
match parse_command(input).expect("parse delete") { match parse_command(input).expect("parse delete") {
Command::SqlDelete { sql, target_table, returning } => { Command::SqlDelete {
rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)) sql,
} target_table,
returning,
} => rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)),
other => panic!("expected Command::SqlDelete, got {other:?}"), other => panic!("expected Command::SqlDelete, got {other:?}"),
} }
} }
@@ -95,8 +96,20 @@ fn run_delete(
/// `ON DELETE CASCADE`. Seeds Alice (1) with two orders (10, 11) /// `ON DELETE CASCADE`. Seeds Alice (1) with two orders (10, 11)
/// and Bob (2) with one order (12). /// and Bob (2) with one order (12).
fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); create_cols(
create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); db,
rt,
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
&["id"],
);
create_cols(
db,
rt,
"Orders",
&[("id", Type::Int), ("CustId", Type::Int)],
&["id"],
);
rt.block_on(db.add_relationship( rt.block_on(db.add_relationship(
Some("places".to_string()), Some("places".to_string()),
"Customers".to_string(), "Customers".to_string(),
@@ -109,16 +122,28 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
None, None,
)) ))
.expect("add cascade relationship"); .expect("add cascade relationship");
seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')", "Customers"); seed(
seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)", "Orders"); db,
rt,
"insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')",
"Customers",
);
seed(
db,
rt,
"insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)",
"Orders",
);
} }
#[test] #[test]
fn parse_path_lowers_sql_delete_to_command() { fn parse_path_lowers_sql_delete_to_command() {
let command = parse_command("delete from Orders where id = 1") let command =
.expect("delete parses in advanced mode"); parse_command("delete from Orders where id = 1").expect("delete parses in advanced mode");
match command { match command {
Command::SqlDelete { sql, target_table, .. } => { Command::SqlDelete {
sql, target_table, ..
} => {
assert_eq!(sql, "delete from Orders where id = 1"); assert_eq!(sql, "delete from Orders where id = 1");
assert_eq!(target_table, "Orders"); assert_eq!(target_table, "Orders");
} }
@@ -130,14 +155,28 @@ fn parse_path_lowers_sql_delete_to_command() {
fn delete_with_where_persists() { fn delete_with_where_persists() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t"); &db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, v) values (1, 'gone'), (2, 'keep')",
"t",
);
let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs"); let result = run_delete(&db, &rt, "delete from t where id = 1").expect("delete runs");
assert_eq!(result.rows_affected, 1, "one row deleted"); assert_eq!(result.rows_affected, 1, "one row deleted");
assert!(result.cascade.is_empty(), "no children, no cascade"); assert!(result.cascade.is_empty(), "no children, no cascade");
let csv = read_csv(&project, "t").expect("t.csv"); let csv = read_csv(&project, "t").expect("t.csv");
assert!(csv.contains("keep"), "untouched row preserved: {csv:?}"); assert!(csv.contains("keep"), "untouched row preserved: {csv:?}");
assert!(!csv.contains("gone"), "deleted row removed from CSV: {csv:?}"); assert!(
!csv.contains("gone"),
"deleted row removed from CSV: {csv:?}"
);
} }
#[test] #[test]
@@ -145,18 +184,35 @@ fn delete_without_where_runs_across_all_rows() {
// ADR-0030 §12: no `--all-rows` rail. // ADR-0030 §12: no `--all-rows` rail.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')", "t"); &db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, v) values (1, 'a'), (2, 'b'), (3, 'c')",
"t",
);
let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs"); let result = run_delete(&db, &rt, "delete from t").expect("unfiltered delete runs");
assert_eq!(result.rows_affected, 3, "all rows deleted"); assert_eq!(result.rows_affected, 3, "all rows deleted");
// Empty tables produce no CSV (CLAUDE.md persistence note), so the // Empty tables produce no CSV (CLAUDE.md persistence note), so the
// file is either absent or has only a header — either way, no data. // file is either absent or has only a header — either way, no data.
let csv = read_csv(&project, "t").unwrap_or_default(); let csv = read_csv(&project, "t").unwrap_or_default();
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}"); assert!(
!csv.contains('a') && !csv.contains('b') && !csv.contains('c'),
"no rows left: {csv:?}"
);
let remaining = rt let remaining = rt
.block_on(db.query_data("t".to_string(), None, None)) .block_on(db.query_data("t".to_string(), None, None))
.expect("query t"); .expect("query t");
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete"); assert!(
remaining.rows.is_empty(),
"table empty after unfiltered delete"
);
} }
#[test] #[test]
@@ -165,8 +221,8 @@ fn cascade_delete_reports_summary_and_repersists_child() {
let rt = rt(); let rt = rt();
cascade_fixture(&db, &rt); cascade_fixture(&db, &rt);
// Delete Alice (customer 1) — cascades to her two orders (10, 11). // Delete Alice (customer 1) — cascades to her two orders (10, 11).
let result = run_delete(&db, &rt, "delete from Customers where id = 1") let result =
.expect("cascading delete runs"); run_delete(&db, &rt, "delete from Customers where id = 1").expect("cascading delete runs");
assert_eq!(result.rows_affected, 1, "one parent row deleted"); assert_eq!(result.rows_affected, 1, "one parent row deleted");
assert_eq!(result.cascade.len(), 1, "one cascade relationship reported"); assert_eq!(result.cascade.len(), 1, "one cascade relationship reported");
let effect = &result.cascade[0]; let effect = &result.cascade[0];
@@ -177,9 +233,14 @@ fn cascade_delete_reports_summary_and_repersists_child() {
assert_eq!(effect.rows_changed, 2, "both of Alice's orders cascaded"); assert_eq!(effect.rows_changed, 2, "both of Alice's orders cascaded");
// The child CSV must be re-persisted to reflect the cascade. // The child CSV must be re-persisted to reflect the cascade.
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}"); assert!(
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"), orders_csv.contains("12"),
"Alice's cascaded orders gone from CSV: {orders_csv:?}"); "Bob's order (12) preserved: {orders_csv:?}"
);
assert!(
!orders_csv.contains("10") && !orders_csv.contains("11"),
"Alice's cascaded orders gone from CSV: {orders_csv:?}"
);
} }
#[test] #[test]
@@ -193,8 +254,8 @@ fn cascade_parity_with_dsl() {
let (_p_sql, db_sql, _d_sql) = open_project_db(); let (_p_sql, db_sql, _d_sql) = open_project_db();
cascade_fixture(&db_sql, &rt); cascade_fixture(&db_sql, &rt);
let sql_result = run_delete(&db_sql, &rt, "delete from Customers where id = 1") let sql_result =
.expect("SQL delete runs"); run_delete(&db_sql, &rt, "delete from Customers where id = 1").expect("SQL delete runs");
let (_p_dsl, db_dsl, _d_dsl) = open_project_db(); let (_p_dsl, db_dsl, _d_dsl) = open_project_db();
cascade_fixture(&db_dsl, &rt); cascade_fixture(&db_dsl, &rt);
@@ -206,8 +267,14 @@ fn cascade_parity_with_dsl() {
)) ))
.expect("DSL delete runs"); .expect("DSL delete runs");
assert_eq!(sql_result.rows_affected, dsl_result.rows_affected, "row counts match"); assert_eq!(
assert_eq!(sql_result.cascade, dsl_result.cascade, "cascade summaries identical"); sql_result.rows_affected, dsl_result.rows_affected,
"row counts match"
);
assert_eq!(
sql_result.cascade, dsl_result.cascade,
"cascade summaries identical"
);
} }
#[test] #[test]
@@ -229,9 +296,14 @@ fn r2_where_with_subquery() {
assert_eq!(result.rows_affected, 2, "Alice's two orders deleted"); assert_eq!(result.rows_affected, 2, "Alice's two orders deleted");
assert!(result.cascade.is_empty(), "Orders has no cascade children"); assert!(result.cascade.is_empty(), "Orders has no cascade children");
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!(orders_csv.contains("12"), "Bob's order preserved: {orders_csv:?}"); assert!(
assert!(!orders_csv.contains("10") && !orders_csv.contains("11"), orders_csv.contains("12"),
"Alice's orders deleted: {orders_csv:?}"); "Bob's order preserved: {orders_csv:?}"
);
assert!(
!orders_csv.contains("10") && !orders_csv.contains("11"),
"Alice's orders deleted: {orders_csv:?}"
);
} }
#[test] #[test]
@@ -255,10 +327,15 @@ fn r2_cascade_with_subquery_where() {
.expect("cascade + subquery-WHERE delete runs"); .expect("cascade + subquery-WHERE delete runs");
assert_eq!(result.rows_affected, 1, "Alice deleted"); assert_eq!(result.rows_affected, 1, "Alice deleted");
assert_eq!(result.cascade.len(), 1, "one cascade relationship"); assert_eq!(result.cascade.len(), 1, "one cascade relationship");
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice orders cascaded"); assert_eq!(
result.cascade[0].rows_changed, 2,
"both Alice orders cascaded"
);
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!(orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"), assert!(
"only Bob's order remains: {orders_csv:?}"); orders_csv.contains("12") && !orders_csv.contains("10") && !orders_csv.contains("11"),
"only Bob's order remains: {orders_csv:?}"
);
} }
#[test] #[test]
@@ -269,9 +346,27 @@ fn cascade_to_two_children_reports_both() {
// emitting more than one effect. // emitting more than one effect.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); create_cols(
create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); &db,
create_cols(&db, &rt, "Reviews", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); &rt,
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
&["id"],
);
create_cols(
&db,
&rt,
"Orders",
&[("id", Type::Int), ("CustId", Type::Int)],
&["id"],
);
create_cols(
&db,
&rt,
"Reviews",
&[("id", Type::Int), ("CustId", Type::Int)],
&["id"],
);
for (child, name) in [("Orders", "places"), ("Reviews", "writes")] { for (child, name) in [("Orders", "places"), ("Reviews", "writes")] {
rt.block_on(db.add_relationship( rt.block_on(db.add_relationship(
Some(name.to_string()), Some(name.to_string()),
@@ -286,14 +381,33 @@ fn cascade_to_two_children_reports_both() {
)) ))
.unwrap_or_else(|e| panic!("add rel {name}: {e:?}")); .unwrap_or_else(|e| panic!("add rel {name}: {e:?}"));
} }
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers"); seed(
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1), (11, 1)", "Orders"); &db,
seed(&db, &rt, "insert into Reviews (id, CustId) values (20, 1)", "Reviews"); &rt,
"insert into Customers (id, Name) values (1, 'Alice')",
"Customers",
);
seed(
&db,
&rt,
"insert into Orders (id, CustId) values (10, 1), (11, 1)",
"Orders",
);
seed(
&db,
&rt,
"insert into Reviews (id, CustId) values (20, 1)",
"Reviews",
);
let result = run_delete(&db, &rt, "delete from Customers where id = 1") let result = run_delete(&db, &rt, "delete from Customers where id = 1")
.expect("cascade-to-two delete runs"); .expect("cascade-to-two delete runs");
assert_eq!(result.rows_affected, 1); assert_eq!(result.rows_affected, 1);
assert_eq!(result.cascade.len(), 2, "both cascade relationships reported"); assert_eq!(
result.cascade.len(),
2,
"both cascade relationships reported"
);
let by_child: std::collections::HashMap<&str, i64> = result let by_child: std::collections::HashMap<&str, i64> = result
.cascade .cascade
.iter() .iter()
@@ -302,9 +416,16 @@ fn cascade_to_two_children_reports_both() {
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded"); assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded"); assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
// Both child CSVs re-persisted to the post-cascade (empty) state. // Both child CSVs re-persisted to the post-cascade (empty) state.
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap(); let orders = rt
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap(); .block_on(db.query_data("Orders".to_string(), None, None))
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied"); .unwrap();
let reviews = rt
.block_on(db.query_data("Reviews".to_string(), None, None))
.unwrap();
assert!(
orders.rows.is_empty() && reviews.rows.is_empty(),
"both children emptied"
);
let _ = &project; let _ = &project;
} }
@@ -318,11 +439,19 @@ fn delete_childless_parent_reports_no_cascade() {
let rt = rt(); let rt = rt();
cascade_fixture(&db, &rt); cascade_fixture(&db, &rt);
// Carol (3) exists with no orders; deleting her cascades nothing. // Carol (3) exists with no orders; deleting her cascades nothing.
seed(&db, &rt, "insert into Customers (id, Name) values (3, 'Carol')", "Customers"); seed(
&db,
&rt,
"insert into Customers (id, Name) values (3, 'Carol')",
"Customers",
);
let result = run_delete(&db, &rt, "delete from Customers where id = 3") let result = run_delete(&db, &rt, "delete from Customers where id = 3")
.expect("childless-parent delete runs"); .expect("childless-parent delete runs");
assert_eq!(result.rows_affected, 1, "Carol deleted"); assert_eq!(result.rows_affected, 1, "Carol deleted");
assert!(result.cascade.is_empty(), "no children → no cascade effect reported"); assert!(
result.cascade.is_empty(),
"no children → no cascade effect reported"
);
// All three orders untouched. // All three orders untouched.
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv");
assert!( assert!(
@@ -340,8 +469,20 @@ fn delete_violating_fk_fails_and_persists_nothing() {
// parent row survives and history records no line. // parent row survives and history records no line.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); create_cols(
create_cols(&db, &rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); &db,
&rt,
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
&["id"],
);
create_cols(
&db,
&rt,
"Orders",
&[("id", Type::Int), ("CustId", Type::Int)],
&["id"],
);
rt.block_on(db.add_relationship( rt.block_on(db.add_relationship(
Some("places".to_string()), Some("places".to_string()),
"Customers".to_string(), "Customers".to_string(),
@@ -354,18 +495,40 @@ fn delete_violating_fk_fails_and_persists_nothing() {
None, None,
)) ))
.expect("add NO ACTION relationship"); .expect("add NO ACTION relationship");
seed(&db, &rt, "insert into Customers (id, Name) values (1, 'Alice')", "Customers"); seed(
seed(&db, &rt, "insert into Orders (id, CustId) values (10, 1)", "Orders"); &db,
&rt,
"insert into Customers (id, Name) values (1, 'Alice')",
"Customers",
);
seed(
&db,
&rt,
"insert into Orders (id, CustId) values (10, 1)",
"Orders",
);
let input = "delete from Customers where id = 1"; let input = "delete from Customers where id = 1";
let result = run_delete(&db, &rt, input); let result = run_delete(&db, &rt, input);
assert!(result.is_err(), "delete of a referenced parent must be rejected"); assert!(
result.is_err(),
"delete of a referenced parent must be rejected"
);
// Rolled back: Alice survives. // Rolled back: Alice survives.
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None)).unwrap(); let customers = rt
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete"); .block_on(db.query_data("Customers".to_string(), None, None))
.unwrap();
assert_eq!(
customers.rows.len(),
1,
"parent row preserved after rejected delete"
);
// No history line for the failed statement (written only on success). // No history line for the failed statement (written only on success).
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default(); let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
assert!(!history.contains(input), "failed delete not logged: {history:?}"); assert!(
!history.contains(input),
"failed delete not logged: {history:?}"
);
} }
#[test] #[test]
@@ -378,7 +541,13 @@ fn self_referential_cascade_counts_only_cascaded_rows() {
// direct delete). Without the self-ref correction this reports 3. // direct delete). Without the self-ref correction this reports 3.
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "T", &[("id", Type::Int), ("ParentId", Type::Int)], &["id"]); create_cols(
&db,
&rt,
"T",
&[("id", Type::Int), ("ParentId", Type::Int)],
&["id"],
);
rt.block_on(db.add_relationship( rt.block_on(db.add_relationship(
Some("parent_of".to_string()), Some("parent_of".to_string()),
"T".to_string(), "T".to_string(),
@@ -391,11 +560,22 @@ fn self_referential_cascade_counts_only_cascaded_rows() {
None, None,
)) ))
.expect("add self-referential relationship"); .expect("add self-referential relationship");
seed(&db, &rt, "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)", "T"); seed(
let result = &db,
run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs"); &rt,
assert_eq!(result.rows_affected, 1, "one row matched the WHERE directly"); "insert into T (id, ParentId) values (1, null), (2, 1), (3, 2)",
assert_eq!(result.cascade.len(), 1, "self-ref relationship reported once"); "T",
);
let result = run_delete(&db, &rt, "delete from T where id = 1").expect("self-ref delete runs");
assert_eq!(
result.rows_affected, 1,
"one row matched the WHERE directly"
);
assert_eq!(
result.cascade.len(),
1,
"self-ref relationship reported once"
);
assert_eq!( assert_eq!(
result.cascade[0].rows_changed, 2, result.cascade[0].rows_changed, 2,
"only the 2 cascaded rows, not the directly-deleted root too" "only the 2 cascaded rows, not the directly-deleted root too"
@@ -421,8 +601,19 @@ fn internal_target_table_rejected_at_parse() {
fn delete_returning_yields_predelete_row() { fn delete_returning_yields_predelete_row() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, v) values (1, 'gone'), (2, 'keep')", "t"); &db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, v) values (1, 'gone'), (2, 'keep')",
"t",
);
let result = run_delete(&db, &rt, "delete from t where id = 1 returning *") let result = run_delete(&db, &rt, "delete from t where id = 1 returning *")
.expect("DELETE … RETURNING * runs"); .expect("DELETE … RETURNING * runs");
assert_eq!(result.rows_affected, 1, "one row deleted"); assert_eq!(result.rows_affected, 1, "one row deleted");
@@ -431,7 +622,10 @@ fn delete_returning_yields_predelete_row() {
assert_eq!(result.data.rows[0][1], Some("gone".to_string())); assert_eq!(result.data.rows[0][1], Some("gone".to_string()));
// And it really is gone from the table. // And it really is gone from the table.
let csv = read_csv(&project, "t").expect("t.csv"); let csv = read_csv(&project, "t").expect("t.csv");
assert!(!csv.contains("gone") && csv.contains("keep"), "row actually deleted: {csv:?}"); assert!(
!csv.contains("gone") && csv.contains("keep"),
"row actually deleted: {csv:?}"
);
} }
#[test] #[test]
@@ -449,5 +643,8 @@ fn delete_returning_with_cascade_surfaces_both() {
// Cascade summary still computed alongside the result set. // Cascade summary still computed alongside the result set.
assert_eq!(result.cascade.len(), 1, "cascade reported"); assert_eq!(result.cascade.len(), 1, "cascade reported");
assert_eq!(result.cascade[0].child_table, "Orders"); assert_eq!(result.cascade[0].child_table, "Orders");
assert_eq!(result.cascade[0].rows_changed, 2, "both Alice's orders cascaded"); assert_eq!(
result.cascade[0].rows_changed, 2,
"both Alice's orders cascaded"
);
} }
+233 -63
View File
@@ -55,8 +55,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -116,15 +115,18 @@ fn run_update(
input: &str, input: &str,
) -> Result<UpdateResult, DbError> { ) -> Result<UpdateResult, DbError> {
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) { match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on( Command::SqlUpdate {
db.run_sql_update_with_literals( sql,
sql, target_table,
Some(input.to_string()), returning,
target_table, set_literals,
returning, } => rt.block_on(db.run_sql_update_with_literals(
set_literals, sql,
), Some(input.to_string()),
), target_table,
returning,
set_literals,
)),
other => panic!("expected Command::SqlUpdate from {input:?}, got {other:?}"), other => panic!("expected Command::SqlUpdate from {input:?}, got {other:?}"),
} }
} }
@@ -135,9 +137,11 @@ fn run_delete(
input: &str, input: &str,
) -> Result<DeleteResult, DbError> { ) -> Result<DeleteResult, DbError> {
match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) { match parse_command(input).unwrap_or_else(|e| panic!("parse {input:?}: {e:?}")) {
Command::SqlDelete { sql, target_table, returning } => rt.block_on( Command::SqlDelete {
db.run_sql_delete(sql, Some(input.to_string()), target_table, returning), sql,
), target_table,
returning,
} => rt.block_on(db.run_sql_delete(sql, Some(input.to_string()), target_table, returning)),
other => panic!("expected Command::SqlDelete from {input:?}, got {other:?}"), other => panic!("expected Command::SqlDelete from {input:?}, got {other:?}"),
} }
} }
@@ -162,9 +166,25 @@ fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Op
fn e2e_insert_select_cross_table_copies_rows_and_persists_both() { fn e2e_insert_select_cross_table_copies_rows_and_persists_both() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "source", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
create_cols(&db, &rt, "archive", &[("id", Type::Int), ("v", Type::Text)], &["id"]); &db,
seed(&db, &rt, "insert into source (id, v) values (1, 'x'), (2, 'y')"); &rt,
"source",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
create_cols(
&db,
&rt,
"archive",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(
&db,
&rt,
"insert into source (id, v) values (1, 'x'), (2, 'y')",
);
let result = run_insert(&db, &rt, "insert into archive select * from source") let result = run_insert(&db, &rt, "insert into archive select * from source")
.expect("INSERT … SELECT runs"); .expect("INSERT … SELECT runs");
@@ -252,15 +272,30 @@ fn e2e_multirow_insert_all_ten_types_roundtrips_and_returning_recovers_each_type
let rows = query(&db, &rt, "allten"); let rows = query(&db, &rt, "allten");
assert_eq!(rows.len(), 2, "both rows persisted"); assert_eq!(rows.len(), 2, "both rows persisted");
let csv = read_csv(&project, "allten").expect("allten.csv"); let csv = read_csv(&project, "allten").expect("allten.csv");
assert!(csv.contains("hi") && csv.contains("yo"), "text round-trips: {csv:?}"); assert!(
assert!(csv.contains("2026-05-23") && csv.contains("2025-01-01"), "dates round-trip: {csv:?}"); csv.contains("hi") && csv.contains("yo"),
"text round-trips: {csv:?}"
);
assert!(
csv.contains("2026-05-23") && csv.contains("2025-01-01"),
"dates round-trip: {csv:?}"
);
let sids: Vec<&str> = rows.iter().filter_map(|r| r[9].as_deref()).collect(); let sids: Vec<&str> = rows.iter().filter_map(|r| r[9].as_deref()).collect();
assert_eq!(sids.len(), 2, "both shortids present"); assert_eq!(sids.len(), 2, "both shortids present");
assert!(sids.iter().all(|s| !s.is_empty()), "shortids non-empty: {sids:?}"); assert!(
assert_ne!(sids[0], sids[1], "auto-filled shortids are distinct: {sids:?}"); sids.iter().all(|s| !s.is_empty()),
"shortids non-empty: {sids:?}"
);
assert_ne!(
sids[0], sids[1],
"auto-filled shortids are distinct: {sids:?}"
);
let sers: Vec<&str> = rows.iter().filter_map(|r| r[0].as_deref()).collect(); let sers: Vec<&str> = rows.iter().filter_map(|r| r[0].as_deref()).collect();
assert!(sers.contains(&"1") && sers.contains(&"2"), "serial auto-incremented: {sers:?}"); assert!(
sers.contains(&"1") && sers.contains(&"2"),
"serial auto-incremented: {sers:?}"
);
} }
// =============================================================== // ===============================================================
@@ -275,18 +310,34 @@ fn e2e_update_with_subquery_in_set() {
&db, &db,
&rt, &rt,
"customers", "customers",
&[("id", Type::Int), ("name", Type::Text), ("last_order", Type::Int)], &[
("id", Type::Int),
("name", Type::Text),
("last_order", Type::Int),
],
&["id"], &["id"],
); );
create_cols( create_cols(
&db, &db,
&rt, &rt,
"orders", "orders",
&[("id", Type::Int), ("cust", Type::Int), ("amount", Type::Int)], &[
("id", Type::Int),
("cust", Type::Int),
("amount", Type::Int),
],
&["id"], &["id"],
); );
seed(&db, &rt, "insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)"); seed(
seed(&db, &rt, "insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)"); &db,
&rt,
"insert into customers (id, name, last_order) values (1, 'A', 0), (2, 'B', 0)",
);
seed(
&db,
&rt,
"insert into orders (id, cust, amount) values (10, 1, 50), (11, 1, 30), (12, 2, 99)",
);
let result = run_update( let result = run_update(
&db, &db,
@@ -298,13 +349,26 @@ fn e2e_update_with_subquery_in_set() {
assert_eq!(result.rows_affected, 2, "both customers updated"); assert_eq!(result.rows_affected, 2, "both customers updated");
let rows = query(&db, &rt, "customers"); let rows = query(&db, &rt, "customers");
let c1 = rows.iter().find(|r| r[0].as_deref() == Some("1")).expect("customer 1"); let c1 = rows
let c2 = rows.iter().find(|r| r[0].as_deref() == Some("2")).expect("customer 2"); .iter()
assert_eq!(c1[2].as_deref(), Some("50"), "customer 1 → max(50, 30) = 50"); .find(|r| r[0].as_deref() == Some("1"))
.expect("customer 1");
let c2 = rows
.iter()
.find(|r| r[0].as_deref() == Some("2"))
.expect("customer 2");
assert_eq!(
c1[2].as_deref(),
Some("50"),
"customer 1 → max(50, 30) = 50"
);
assert_eq!(c2[2].as_deref(), Some("99"), "customer 2 → max(99) = 99"); assert_eq!(c2[2].as_deref(), Some("99"), "customer 2 → max(99) = 99");
let csv = read_csv(&project, "customers").expect("customers.csv"); let csv = read_csv(&project, "customers").expect("customers.csv");
assert!(csv.contains("50") && csv.contains("99"), "CSV reflects the update: {csv:?}"); assert!(
csv.contains("50") && csv.contains("99"),
"CSV reflects the update: {csv:?}"
);
} }
// =============================================================== // ===============================================================
@@ -313,8 +377,20 @@ fn e2e_update_with_subquery_in_set() {
// =============================================================== // ===============================================================
fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) { fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
create_cols(db, rt, "Customers", &[("id", Type::Int), ("Name", Type::Text)], &["id"]); create_cols(
create_cols(db, rt, "Orders", &[("id", Type::Int), ("CustId", Type::Int)], &["id"]); db,
rt,
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
&["id"],
);
create_cols(
db,
rt,
"Orders",
&[("id", Type::Int), ("CustId", Type::Int)],
&["id"],
);
rt.block_on(db.add_relationship( rt.block_on(db.add_relationship(
Some("places".to_string()), Some("places".to_string()),
"Customers".to_string(), "Customers".to_string(),
@@ -327,8 +403,16 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
None, None,
)) ))
.expect("add cascade relationship"); .expect("add cascade relationship");
seed(db, rt, "insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')"); seed(
seed(db, rt, "insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)"); db,
rt,
"insert into Customers (id, Name) values (1, 'Alice'), (2, 'Bob')",
);
seed(
db,
rt,
"insert into Orders (id, CustId) values (10, 1), (11, 1), (12, 2)",
);
} }
#[test] #[test]
@@ -337,8 +421,8 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() {
let rt = rt(); let rt = rt();
cascade_fixture(&db, &rt); cascade_fixture(&db, &rt);
let result = run_delete(&db, &rt, "delete from Customers where id = 1") let result =
.expect("cascading DELETE runs"); run_delete(&db, &rt, "delete from Customers where id = 1").expect("cascading DELETE runs");
assert_eq!(result.rows_affected, 1, "one parent row deleted"); assert_eq!(result.rows_affected, 1, "one parent row deleted");
assert_eq!(result.cascade.len(), 1, "one cascade relationship affected"); assert_eq!(result.cascade.len(), 1, "one cascade relationship affected");
let effect = &result.cascade[0]; let effect = &result.cascade[0];
@@ -346,9 +430,18 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() {
assert_eq!(effect.rows_changed, 2, "Alice's two orders cascaded"); assert_eq!(effect.rows_changed, 2, "Alice's two orders cascaded");
let orders_csv = read_csv(&project, "Orders").expect("Orders.csv re-persisted"); let orders_csv = read_csv(&project, "Orders").expect("Orders.csv re-persisted");
assert!(orders_csv.contains("12"), "Bob's order (12) preserved: {orders_csv:?}"); assert!(
assert!(!orders_csv.contains("10"), "Alice's order 10 cascaded away: {orders_csv:?}"); orders_csv.contains("12"),
assert!(!orders_csv.contains("11"), "Alice's order 11 cascaded away: {orders_csv:?}"); "Bob's order (12) preserved: {orders_csv:?}"
);
assert!(
!orders_csv.contains("10"),
"Alice's order 10 cascaded away: {orders_csv:?}"
);
assert!(
!orders_csv.contains("11"),
"Alice's order 11 cascaded away: {orders_csv:?}"
);
} }
// =============================================================== // ===============================================================
@@ -359,7 +452,13 @@ fn e2e_delete_with_cascade_reports_summary_and_repersists_children() {
fn e2e_upsert_round_trip_do_update_then_do_nothing() { fn e2e_upsert_round_trip_do_update_then_do_nothing() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "kv", &[("id", Type::Int), ("name", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"kv",
&[("id", Type::Int), ("name", Type::Text)],
&["id"],
);
seed(&db, &rt, "insert into kv (id, name) values (1, 'old')"); seed(&db, &rt, "insert into kv (id, name) values (1, 'old')");
// DO UPDATE on a conflict mutates the existing row. // DO UPDATE on a conflict mutates the existing row.
@@ -369,9 +468,15 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() {
"insert into kv (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name", "insert into kv (id, name) values (1, 'new') on conflict (id) do update set name = excluded.name",
) )
.expect("UPSERT DO UPDATE runs"); .expect("UPSERT DO UPDATE runs");
assert_eq!(upd.rows_affected, 1, "DO UPDATE touches the conflicting row"); assert_eq!(
upd.rows_affected, 1,
"DO UPDATE touches the conflicting row"
);
let csv = read_csv(&project, "kv").expect("kv.csv"); let csv = read_csv(&project, "kv").expect("kv.csv");
assert!(csv.contains("new") && !csv.contains("old"), "row updated to 'new': {csv:?}"); assert!(
csv.contains("new") && !csv.contains("old"),
"row updated to 'new': {csv:?}"
);
// DO NOTHING on a conflict is a no-op. // DO NOTHING on a conflict is a no-op.
let nothing = run_insert( let nothing = run_insert(
@@ -382,7 +487,10 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() {
.expect("UPSERT DO NOTHING runs"); .expect("UPSERT DO NOTHING runs");
assert_eq!(nothing.rows_affected, 0, "DO NOTHING changes no rows"); assert_eq!(nothing.rows_affected, 0, "DO NOTHING changes no rows");
let csv = read_csv(&project, "kv").expect("kv.csv"); let csv = read_csv(&project, "kv").expect("kv.csv");
assert!(csv.contains("new") && !csv.contains("ignored"), "row unchanged by DO NOTHING: {csv:?}"); assert!(
csv.contains("new") && !csv.contains("ignored"),
"row unchanged by DO NOTHING: {csv:?}"
);
} }
// =============================================================== // ===============================================================
@@ -393,23 +501,52 @@ fn e2e_upsert_round_trip_do_update_then_do_nothing() {
fn e2e_returning_on_insert_update_delete() { fn e2e_returning_on_insert_update_delete() {
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
let ins = run_insert(&db, &rt, "insert into t (id, v) values (1, 'a') returning id, v") let ins = run_insert(
.expect("INSERT … RETURNING runs"); &db,
assert_eq!(ins.data.rows.len(), 1, "INSERT RETURNING yields the inserted row"); &rt,
"insert into t (id, v) values (1, 'a') returning id, v",
)
.expect("INSERT … RETURNING runs");
assert_eq!(
ins.data.rows.len(),
1,
"INSERT RETURNING yields the inserted row"
);
assert_eq!(ins.data.rows[0][1].as_deref(), Some("a")); assert_eq!(ins.data.rows[0][1].as_deref(), Some("a"));
let upd = run_update(&db, &rt, "update t set v = 'b' where id = 1 returning v") let upd = run_update(&db, &rt, "update t set v = 'b' where id = 1 returning v")
.expect("UPDATE … RETURNING runs"); .expect("UPDATE … RETURNING runs");
assert_eq!(upd.data.rows.len(), 1, "UPDATE RETURNING yields the modified row"); assert_eq!(
upd.data.rows.len(),
1,
"UPDATE RETURNING yields the modified row"
);
assert_eq!(upd.data.rows[0][0].as_deref(), Some("b")); assert_eq!(upd.data.rows[0][0].as_deref(), Some("b"));
let del = run_delete(&db, &rt, "delete from t where id = 1 returning *") let del = run_delete(&db, &rt, "delete from t where id = 1 returning *")
.expect("DELETE … RETURNING runs"); .expect("DELETE … RETURNING runs");
assert_eq!(del.data.rows.len(), 1, "DELETE RETURNING yields the pre-delete row"); assert_eq!(
assert_eq!(del.data.rows[0][1].as_deref(), Some("b"), "pre-delete value surfaced"); del.data.rows.len(),
assert!(query(&db, &rt, "t").is_empty(), "row is gone after the DELETE"); 1,
"DELETE RETURNING yields the pre-delete row"
);
assert_eq!(
del.data.rows[0][1].as_deref(),
Some("b"),
"pre-delete value surfaced"
);
assert!(
query(&db, &rt, "t").is_empty(),
"row is gone after the DELETE"
);
} }
// =============================================================== // ===============================================================
@@ -463,7 +600,7 @@ fn e2e_replay_phase3_dml_forms_from_a_script() {
("1".to_string(), Some("a".to_string())), ("1".to_string(), Some("a".to_string())),
("11".to_string(), Some("a".to_string())), // INSERT…SELECT id+10 ("11".to_string(), Some("a".to_string())), // INSERT…SELECT id+10
("2".to_string(), Some("z".to_string())), // UPDATE ("2".to_string(), Some("z".to_string())), // UPDATE
// id 3 was DELETEd // id 3 was DELETEd
] ]
.into_iter() .into_iter()
.collect::<std::collections::BTreeMap<_, _>>() .collect::<std::collections::BTreeMap<_, _>>()
@@ -481,15 +618,42 @@ fn e2e_replay_phase3_dml_forms_from_a_script() {
fn e2e_out_of_scope_dml_forms_parse_reject() { fn e2e_out_of_scope_dml_forms_parse_reject() {
let cases = [ let cases = [
("OOS-1 DEFAULT VALUES", "insert into t default values"), ("OOS-1 DEFAULT VALUES", "insert into t default values"),
("OOS-2 INSERT OR REPLACE", "insert or replace into t values (1)"), (
("OOS-2 INSERT OR IGNORE", "insert or ignore into t values (1)"), "OOS-2 INSERT OR REPLACE",
("OOS-3 UPDATE … FROM", "update t set a = b.x from other b where t.id = b.id"), "insert or replace into t values (1)",
("OOS-4 WITH … UPDATE", "with x as (select 1) update t set a = 1 where id = 1"), ),
("OOS-4 WITH … DELETE", "with x as (select 1) delete from t where id = 1"), (
("OOS-5 INDEXED BY", "delete from t indexed by idx where id = 1"), "OOS-2 INSERT OR IGNORE",
("OOS-5 NOT INDEXED", "update t not indexed set a = 1 where id = 1"), "insert or ignore into t values (1)",
("OOS-6 multi-statement (DELETE; DELETE)", "delete from t where id = 1; delete from t where id = 2"), ),
("OOS-6 multi-statement (INSERT; INSERT)", "insert into t values (1); insert into t values (2)"), (
"OOS-3 UPDATE … FROM",
"update t set a = b.x from other b where t.id = b.id",
),
(
"OOS-4 WITH … UPDATE",
"with x as (select 1) update t set a = 1 where id = 1",
),
(
"OOS-4 WITH … DELETE",
"with x as (select 1) delete from t where id = 1",
),
(
"OOS-5 INDEXED BY",
"delete from t indexed by idx where id = 1",
),
(
"OOS-5 NOT INDEXED",
"update t not indexed set a = 1 where id = 1",
),
(
"OOS-6 multi-statement (DELETE; DELETE)",
"delete from t where id = 1; delete from t where id = 2",
),
(
"OOS-6 multi-statement (INSERT; INSERT)",
"insert into t values (1); insert into t values (2)",
),
]; ];
for (label, src) in cases { for (label, src) in cases {
assert!( assert!(
@@ -525,7 +689,10 @@ fn e2e_update_all_rows_in_advanced_falls_back_to_dsl() {
assert!( assert!(
matches!( matches!(
parse_command_in_mode("update Orders set total = 42 --all-rows", Mode::Advanced), parse_command_in_mode("update Orders set total = 42 --all-rows", Mode::Advanced),
Ok(Command::Update { filter: RowFilter::AllRows, .. }) Ok(Command::Update {
filter: RowFilter::AllRows,
..
})
), ),
"advanced `update … --all-rows` falls back to the DSL Update", "advanced `update … --all-rows` falls back to the DSL Update",
); );
@@ -599,5 +766,8 @@ fn e2e_validity_indicator_fires_for_sql_dml_diagnostic() {
// And the indicator renders the `[WRN]` label. // And the indicator renders the `[WRN]` label.
app.input_indicator = app.input_validity_verdict(); app.input_indicator = app.input_validity_verdict();
let text = rendered_text(&mut app, &Theme::dark(), 80, 24); let text = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(text.contains("[WRN]"), "the SQL DML warning surfaces as [WRN]:\n{text}"); assert!(
text.contains("[WRN]"),
"the SQL DML warning surfaces as [WRN]:\n{text}"
);
} }
+23 -8
View File
@@ -21,8 +21,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo) let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -33,7 +32,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String { fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
r.block_on(db.sql_create_table( r.block_on(db.sql_create_table(
"T".to_string(), "T".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("email", Type::Text)], vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("email", Type::Text),
],
vec!["id".to_string()], vec!["id".to_string()],
vec![], vec![],
vec![], vec![],
@@ -75,7 +77,10 @@ fn drop_index_removes_an_existing_index_and_shows_the_table() {
match out { match out {
DropIndexOutcome::Dropped(desc) => { DropIndexOutcome::Dropped(desc) => {
assert_eq!(desc.name, "T"); assert_eq!(desc.name, "T");
assert!(desc.indexes.is_empty(), "the index is gone from the structure"); assert!(
desc.indexes.is_empty(),
"the index is gone from the structure"
);
} }
DropIndexOutcome::Skipped => panic!("expected Dropped, got Skipped"), DropIndexOutcome::Skipped => panic!("expected Dropped, got Skipped"),
} }
@@ -105,7 +110,10 @@ fn plain_drop_of_an_absent_index_errors() {
false, false,
Some("drop index ghost_idx".to_string()), Some("drop index ghost_idx".to_string()),
)); ));
assert!(res.is_err(), "plain DROP INDEX on an absent index errors (no IF EXISTS)"); assert!(
res.is_err(),
"plain DROP INDEX on an absent index errors (no IF EXISTS)"
);
} }
#[test] #[test]
@@ -113,11 +121,18 @@ fn drop_index_is_one_undo_step_and_restores_the_index() {
let (_p, db, _d) = open(true); // undo enabled let (_p, db, _d) = open(true); // undo enabled
let r = rt(); let r = rt();
let name = make_t_with_index(&db, &r); let name = make_t_with_index(&db, &r);
r.block_on(db.sql_drop_index(name.clone(), false, Some("drop index T_email_idx".to_string()))) r.block_on(db.sql_drop_index(
.expect("drop index"); name.clone(),
false,
Some("drop index T_email_idx".to_string()),
))
.expect("drop index");
assert!(index_names(&db, &r).is_empty()); assert!(index_names(&db, &r).is_empty());
// One undo brings the index back. // One undo brings the index back.
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step"); assert!(
r.block_on(db.undo()).expect("undo").is_some(),
"the drop was one undo step"
);
assert_eq!(index_names(&db, &r), vec![name], "undo restored the index"); assert_eq!(index_names(&db, &r), vec![name], "undo restored the index");
} }
+66 -19
View File
@@ -22,8 +22,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) { fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo) let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, undo)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -34,7 +33,10 @@ fn open(undo: bool) -> (project::Project, Database, tempfile::TempDir) {
fn make_t(db: &Database, r: &tokio::runtime::Runtime) { fn make_t(db: &Database, r: &tokio::runtime::Runtime) {
r.block_on(db.sql_create_table( r.block_on(db.sql_create_table(
"T".to_string(), "T".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("body", Type::Text)], vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("body", Type::Text),
],
vec!["id".to_string()], vec!["id".to_string()],
vec![], vec![],
vec![], vec![],
@@ -54,7 +56,11 @@ fn drop_table_removes_an_existing_table() {
.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string()))) .block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
.expect("drop"); .expect("drop");
assert!(matches!(out, DropOutcome::Dropped)); assert!(matches!(out, DropOutcome::Dropped));
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string())); assert!(
!r.block_on(db.list_tables())
.unwrap()
.contains(&"T".to_string())
);
} }
#[test] #[test]
@@ -75,8 +81,15 @@ fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
fn plain_drop_of_an_absent_table_errors() { fn plain_drop_of_an_absent_table_errors() {
let (_p, db, _d) = open(false); let (_p, db, _d) = open(false);
let r = rt(); let r = rt();
let res = r.block_on(db.sql_drop_table("Ghost".to_string(), false, Some("drop table Ghost".to_string()))); let res = r.block_on(db.sql_drop_table(
assert!(res.is_err(), "plain DROP TABLE on an absent table errors (no IF EXISTS)"); "Ghost".to_string(),
false,
Some("drop table Ghost".to_string()),
));
assert!(
res.is_err(),
"plain DROP TABLE on an absent table errors (no IF EXISTS)"
);
} }
#[test] #[test]
@@ -87,7 +100,10 @@ fn dropping_a_referenced_parent_is_refused() {
let r = rt(); let r = rt();
r.block_on(db.sql_create_table( r.block_on(db.sql_create_table(
"parent".to_string(), "parent".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()], vec!["id".to_string()],
vec![], vec![],
vec![], vec![],
@@ -98,7 +114,10 @@ fn dropping_a_referenced_parent_is_refused() {
.expect("create parent"); .expect("create parent");
r.block_on(db.sql_create_table( r.block_on(db.sql_create_table(
"child".to_string(), "child".to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("pid", Type::Int)], vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("pid", Type::Int),
],
vec!["id".to_string()], vec!["id".to_string()],
vec![], vec![],
vec![], vec![],
@@ -112,22 +131,36 @@ fn dropping_a_referenced_parent_is_refused() {
inline: true, inline: true,
}], }],
false, false,
Some("create table child (id serial primary key, pid int references parent(id))".to_string()), Some(
"create table child (id serial primary key, pid int references parent(id))".to_string(),
),
)) ))
.expect("create child with FK"); .expect("create child with FK");
// The parent is referenced — refused (even with IF EXISTS, since the // The parent is referenced — refused (even with IF EXISTS, since the
// table *does* exist; the refusal is about the relationship). // table *does* exist; the refusal is about the relationship).
assert!( assert!(
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string()))) r.block_on(db.sql_drop_table(
.is_err(), "parent".to_string(),
false,
Some("drop table parent".to_string())
))
.is_err(),
"a referenced parent can't be dropped" "a referenced parent can't be dropped"
); );
// Dropping the child first succeeds, then the parent. // Dropping the child first succeeds, then the parent.
r.block_on(db.sql_drop_table("child".to_string(), false, Some("drop table child".to_string()))) r.block_on(db.sql_drop_table(
.expect("drop child"); "child".to_string(),
r.block_on(db.sql_drop_table("parent".to_string(), false, Some("drop table parent".to_string()))) false,
.expect("now the parent drops"); Some("drop table child".to_string()),
))
.expect("drop child");
r.block_on(db.sql_drop_table(
"parent".to_string(),
false,
Some("drop table parent".to_string()),
))
.expect("now the parent drops");
} }
#[test] #[test]
@@ -138,17 +171,31 @@ fn drop_table_is_one_undo_step_and_restores_data() {
r.block_on(db.insert( r.block_on(db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "body".to_string()]), Some(vec!["id".to_string(), "body".to_string()]),
vec![Value::Number("1".to_string()), Value::Text("hi".to_string())], vec![
Value::Number("1".to_string()),
Value::Text("hi".to_string()),
],
Some("insert".to_string()), Some("insert".to_string()),
)) ))
.expect("row"); .expect("row");
r.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string()))) r.block_on(db.sql_drop_table("T".to_string(), false, Some("drop table T".to_string())))
.expect("drop"); .expect("drop");
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"T".to_string())); assert!(
!r.block_on(db.list_tables())
.unwrap()
.contains(&"T".to_string())
);
// One undo brings the table — and its row — back. // One undo brings the table — and its row — back.
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step"); assert!(
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string())); r.block_on(db.undo()).expect("undo").is_some(),
"the drop was one undo step"
);
assert!(
r.block_on(db.list_tables())
.unwrap()
.contains(&"T".to_string())
);
let data = r let data = r
.block_on(db.query_data("T".to_string(), None, None)) .block_on(db.query_data("T".to_string(), None, None))
.expect("query"); .expect("query");
+422 -101
View File
@@ -41,8 +41,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -86,7 +85,10 @@ fn single_row_insert_persists_and_counts() {
.expect("insert runs"); .expect("insert runs");
assert_eq!(result.rows_affected, 1, "one row inserted"); assert_eq!(result.rows_affected, 1, "one row inserted");
let csv = read_csv(&project, "T").expect("T.csv written after insert"); let csv = read_csv(&project, "T").expect("T.csv written after insert");
assert!(csv.contains("Ada"), "CSV reflects the inserted row: {csv:?}"); assert!(
csv.contains("Ada"),
"CSV reflects the inserted row: {csv:?}"
);
} }
#[test] #[test]
@@ -193,7 +195,10 @@ fn failed_multi_row_insert_is_atomic() {
String::new(), String::new(),
false, false,
)); ));
assert!(outcome.is_err(), "multi-row PK conflict must fail: {outcome:?}"); assert!(
outcome.is_err(),
"multi-row PK conflict must fail: {outcome:?}"
);
let csv = read_csv(&project, "T").expect("T.csv still present"); let csv = read_csv(&project, "T").expect("T.csv still present");
assert!( assert!(
csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"), csv.contains("existing") && !csv.contains("fresh") && !csv.contains("collides"),
@@ -208,7 +213,9 @@ fn parse_path_lowers_sqlinsert_scaffold_to_command() {
let command = parse_command("insert into Orders (id, total) values (1, 99.5)") let command = parse_command("insert into Orders (id, total) values (1, 99.5)")
.expect("insert parses in advanced mode"); .expect("insert parses in advanced mode");
match command { match command {
Command::SqlInsert { sql, target_table, .. } => { Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)"); assert_eq!(sql, "insert into Orders (id, total) values (1, 99.5)");
assert_eq!(target_table, "Orders"); assert_eq!(target_table, "Orders");
} }
@@ -248,7 +255,9 @@ fn parse_path_lowers_insert_select_to_command() {
let command = parse_command("insert into archive select * from source") let command = parse_command("insert into archive select * from source")
.expect("INSERT … SELECT parses in advanced mode"); .expect("INSERT … SELECT parses in advanced mode");
match command { match command {
Command::SqlInsert { sql, target_table, .. } => { Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!(sql, "insert into archive select * from source"); assert_eq!(sql, "insert into archive select * from source");
assert_eq!(target_table, "archive"); assert_eq!(target_table, "archive");
} }
@@ -259,12 +268,13 @@ fn parse_path_lowers_insert_select_to_command() {
#[test] #[test]
fn parse_path_lowers_with_prefixed_insert_select() { fn parse_path_lowers_with_prefixed_insert_select() {
// R4: a WITH-prefixed SELECT row source lowers verbatim. // R4: a WITH-prefixed SELECT row source lowers verbatim.
let command = parse_command( let command =
"insert into archive with t as (select * from orders) select * from t", parse_command("insert into archive with t as (select * from orders) select * from t")
) .expect("WITH-prefixed INSERT … SELECT parses");
.expect("WITH-prefixed INSERT … SELECT parses");
match command { match command {
Command::SqlInsert { sql, target_table, .. } => { Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!( assert_eq!(
sql, sql,
"insert into archive with t as (select * from orders) select * from t", "insert into archive with t as (select * from orders) select * from t",
@@ -472,7 +482,13 @@ fn csv_rows(project: &project::Project, table: &str) -> Vec<Vec<String>> {
fn values_autofills_omitted_shortid_pk() { fn values_autofills_omitted_shortid_pk() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x')") let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
.expect("auto-fill insert runs"); .expect("auto-fill insert runs");
assert_eq!(result.rows_affected, 1); assert_eq!(result.rows_affected, 1);
@@ -486,25 +502,36 @@ fn values_autofills_omitted_shortid_pk() {
fn values_multirow_autofills_distinct_shortids() { fn values_multirow_autofills_distinct_shortids() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
let result = run_sqlinsert(
&db, &db,
&rt, &rt,
"insert into t (label) values ('a'), ('b'), ('c')", "t",
) &[("id", Type::ShortId), ("label", Type::Text)],
.expect("multi-row auto-fill runs"); &["id"],
);
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('a'), ('b'), ('c')")
.expect("multi-row auto-fill runs");
assert_eq!(result.rows_affected, 3); assert_eq!(result.rows_affected, 3);
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect(); let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
assert_eq!(ids.len(), 3, "three DISTINCT non-empty shortids: {rows:?}"); assert_eq!(ids.len(), 3, "three DISTINCT non-empty shortids: {rows:?}");
assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}"); assert!(
rows.iter().all(|r| !r[0].is_empty()),
"no empty id: {rows:?}"
);
} }
#[test] #[test]
fn explicit_shortid_value_is_respected() { fn explicit_shortid_value_is_respected() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
// The user provided `id` explicitly — it must be honoured // The user provided `id` explicitly — it must be honoured
// verbatim (the override WARNING is sub-phase 3i). // verbatim (the override WARNING is sub-phase 3i).
run_sqlinsert( run_sqlinsert(
@@ -521,10 +548,21 @@ fn explicit_shortid_value_is_respected() {
fn insert_select_autofills_distinct_shortids() { fn insert_select_autofills_distinct_shortids() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "source", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
create_cols(&db, &rt, "target", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); &db,
run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')") &rt,
.expect("seed source"); "source",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
create_cols(
&db,
&rt,
"target",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "insert into source (label) values ('a'), ('b')").expect("seed source");
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
&rt, &rt,
@@ -535,7 +573,10 @@ fn insert_select_autofills_distinct_shortids() {
let rows = csv_rows(&project, "target"); let rows = csv_rows(&project, "target");
let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect(); let ids: std::collections::HashSet<&String> = rows.iter().map(|r| &r[0]).collect();
assert_eq!(ids.len(), 2, "two DISTINCT fresh shortids: {rows:?}"); assert_eq!(ids.len(), 2, "two DISTINCT fresh shortids: {rows:?}");
assert!(rows.iter().all(|r| !r[0].is_empty()), "no empty id: {rows:?}"); assert!(
rows.iter().all(|r| !r[0].is_empty()),
"no empty id: {rows:?}"
);
} }
#[test] #[test]
@@ -547,11 +588,14 @@ fn combined_serial_and_shortid_autofill() {
&db, &db,
&rt, &rt,
"t", "t",
&[("id", Type::Serial), ("code", Type::ShortId), ("name", Type::Text)], &[
("id", Type::Serial),
("code", Type::ShortId),
("name", Type::Text),
],
&["id"], &["id"],
); );
run_sqlinsert(&db, &rt, "insert into t (name) values ('x')") run_sqlinsert(&db, &rt, "insert into t (name) values ('x')").expect("combined auto-fill runs");
.expect("combined auto-fill runs");
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "{rows:?}"); assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0][0], "1", "serial PK engine-filled: {rows:?}"); assert_eq!(rows[0][0], "1", "serial PK engine-filled: {rows:?}");
@@ -566,7 +610,13 @@ fn shortid_autofill_respects_mixed_case_column_name() {
// schema name `MyId`, not a lowercased form. // schema name `MyId`, not a lowercased form.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("MyId", Type::ShortId), ("label", Type::Text)], &["MyId"]); create_cols(
&db,
&rt,
"t",
&[("MyId", Type::ShortId), ("label", Type::Text)],
&["MyId"],
);
run_sqlinsert(&db, &rt, "insert into t (label) values ('x')") run_sqlinsert(&db, &rt, "insert into t (label) values ('x')")
.expect("mixed-case shortid auto-fill runs"); .expect("mixed-case shortid auto-fill runs");
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
@@ -584,7 +634,11 @@ fn two_shortids_pk_and_nonpk_both_autofill_distinctly() {
&db, &db,
&rt, &rt,
"t", "t",
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)], &[
("id", Type::ShortId),
("code", Type::ShortId),
("label", Type::Text),
],
&["id"], &["id"],
); );
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x'), ('y')") let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x'), ('y')")
@@ -611,7 +665,11 @@ fn two_shortids_one_provided_one_autofilled() {
&db, &db,
&rt, &rt,
"t", "t",
&[("id", Type::ShortId), ("code", Type::ShortId), ("label", Type::Text)], &[
("id", Type::ShortId),
("code", Type::ShortId),
("label", Type::Text),
],
&["id"], &["id"],
); );
run_sqlinsert(&db, &rt, "insert into t (id, label) values ('myid', 'x')") run_sqlinsert(&db, &rt, "insert into t (id, label) values ('myid', 'x')")
@@ -631,7 +689,11 @@ fn compound_pk_with_shortid_member_autofills() {
&db, &db,
&rt, &rt,
"t", "t",
&[("id", Type::ShortId), ("region", Type::Int), ("label", Type::Text)], &[
("id", Type::ShortId),
("region", Type::Int),
("label", Type::Text),
],
&["id", "region"], &["id", "region"],
); );
run_sqlinsert(&db, &rt, "insert into t (region, label) values (1, 'x')") run_sqlinsert(&db, &rt, "insert into t (region, label) values (1, 'x')")
@@ -653,14 +715,23 @@ fn autofill_does_not_mask_arity_mismatch() {
// engine rather than mask the error.) // engine rather than mask the error.)
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) values ('a', 'b')"); let outcome = run_sqlinsert(&db, &rt, "insert into t (label) values ('a', 'b')");
assert!( assert!(
outcome.is_err(), outcome.is_err(),
"arity mismatch must be rejected, not masked: {outcome:?}", "arity mismatch must be rejected, not masked: {outcome:?}",
); );
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
assert!(rows.is_empty(), "no row should land on a rejected insert: {rows:?}"); assert!(
rows.is_empty(),
"no row should land on a rejected insert: {rows:?}"
);
} }
#[test] #[test]
@@ -672,14 +743,19 @@ fn sql_insert_autofills_omitted_nonpk_serial() {
// shortid auto-fill, which already runs on this path. // shortid auto-fill, which already runs on this path.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("seq", Type::Serial)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("seq", Type::Serial)],
&["id"],
);
// Single row, omitting the non-PK serial `seq`. // Single row, omitting the non-PK serial `seq`.
run_sqlinsert(&db, &rt, "insert into t (id) values (10)").expect("single-row insert runs"); run_sqlinsert(&db, &rt, "insert into t (id) values (10)").expect("single-row insert runs");
// Multi-row, omitting `seq` — each row gets a distinct, increasing // Multi-row, omitting `seq` — each row gets a distinct, increasing
// serial continuing from the current MAX. // serial continuing from the current MAX.
run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)") run_sqlinsert(&db, &rt, "insert into t (id) values (20), (30)").expect("multi-row insert runs");
.expect("multi-row insert runs");
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
// No NULL serials, and the sequence is 1, 2, 3 across the three rows. // No NULL serials, and the sequence is 1, 2, 3 across the three rows.
@@ -700,11 +776,26 @@ fn autofill_insert_select_wider_projection_is_rejected() {
// to the engine instead of dropping the extra projection. // to the engine instead of dropping the extra projection.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "src", &[("a", Type::Text), ("b", Type::Text)], &["a"]); create_cols(
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); &db,
&rt,
"src",
&[("a", Type::Text), ("b", Type::Text)],
&["a"],
);
create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "insert into src (a, b) values ('p', 'q')").expect("seed"); run_sqlinsert(&db, &rt, "insert into src (a, b) values ('p', 'q')").expect("seed");
let outcome = run_sqlinsert(&db, &rt, "insert into t (label) select a, b from src"); let outcome = run_sqlinsert(&db, &rt, "insert into t (label) select a, b from src");
assert!(outcome.is_err(), "wider projection must be rejected: {outcome:?}"); assert!(
outcome.is_err(),
"wider projection must be rejected: {outcome:?}"
);
assert!(csv_rows(&project, "t").is_empty(), "nothing should land"); assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
} }
@@ -724,7 +815,10 @@ fn autofill_insert_select_narrower_projection_is_rejected() {
); );
run_sqlinsert(&db, &rt, "insert into src (a) values ('p')").expect("seed"); run_sqlinsert(&db, &rt, "insert into src (a) values ('p')").expect("seed");
let outcome = run_sqlinsert(&db, &rt, "insert into t (x, y) select a from src"); let outcome = run_sqlinsert(&db, &rt, "insert into t (x, y) select a from src");
assert!(outcome.is_err(), "narrower projection must be rejected: {outcome:?}"); assert!(
outcome.is_err(),
"narrower projection must be rejected: {outcome:?}"
);
assert!(csv_rows(&project, "t").is_empty(), "nothing should land"); assert!(csv_rows(&project, "t").is_empty(), "nothing should land");
} }
@@ -736,11 +830,25 @@ fn autofill_insert_select_narrower_projection_is_rejected() {
fn insert_returning_star_returns_inserted_row() { fn insert_returning_star_returns_inserted_row() {
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]); create_cols(
let result = run_sqlinsert(&db, &rt, "insert into t (id, b) values (1, 'Ada') returning *") &db,
.expect("INSERT … RETURNING * runs"); &rt,
"t",
&[("id", Type::Int), ("b", Type::Text)],
&["id"],
);
let result = run_sqlinsert(
&db,
&rt,
"insert into t (id, b) values (1, 'Ada') returning *",
)
.expect("INSERT … RETURNING * runs");
assert_eq!(result.rows_affected, 1, "one row inserted"); assert_eq!(result.rows_affected, 1, "one row inserted");
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the inserted row"); assert_eq!(
result.data.rows.len(),
1,
"RETURNING yielded the inserted row"
);
assert_eq!(result.data.columns, vec!["id".to_string(), "b".to_string()]); assert_eq!(result.data.columns, vec!["id".to_string(), "b".to_string()]);
assert_eq!(result.data.rows[0][1], Some("Ada".to_string())); assert_eq!(result.data.rows[0][1], Some("Ada".to_string()));
} }
@@ -749,7 +857,13 @@ fn insert_returning_star_returns_inserted_row() {
fn insert_multirow_returning_id_yields_distinct_rows() { fn insert_multirow_returning_id_yields_distinct_rows() {
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("b", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("b", Type::Text)],
&["id"],
);
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
&rt, &rt,
@@ -760,7 +874,12 @@ fn insert_multirow_returning_id_yields_distinct_rows() {
assert_eq!(result.data.columns, vec!["id".to_string()]); assert_eq!(result.data.columns, vec!["id".to_string()]);
let ids: std::collections::BTreeSet<_> = let ids: std::collections::BTreeSet<_> =
result.data.rows.iter().map(|r| r[0].clone()).collect(); result.data.rows.iter().map(|r| r[0].clone()).collect();
assert_eq!(ids.len(), 3, "three distinct ids returned: {:?}", result.data.rows); assert_eq!(
ids.len(),
3,
"three distinct ids returned: {:?}",
result.data.rows
);
} }
#[test] #[test]
@@ -771,16 +890,33 @@ fn insert_returning_autofills_shortid_and_returns_it() {
// surfaces in the returned row. // surfaces in the returned row.
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x') returning *") let result = run_sqlinsert(&db, &rt, "insert into t (label) values ('x') returning *")
.expect("auto-fill INSERT … RETURNING * runs"); .expect("auto-fill INSERT … RETURNING * runs");
assert_eq!(result.rows_affected, 1, "one row inserted (RETURNING-counted)"); assert_eq!(
result.rows_affected, 1,
"one row inserted (RETURNING-counted)"
);
assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row"); assert_eq!(result.data.rows.len(), 1, "RETURNING yielded the row");
// `id` is the auto-filled shortid column; it must be non-empty in // `id` is the auto-filled shortid column; it must be non-empty in
// the returned row (proving the rewrite kept RETURNING). // the returned row (proving the rewrite kept RETURNING).
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column"); let id_idx = result
.data
.columns
.iter()
.position(|c| c == "id")
.expect("id column");
let id_val = result.data.rows[0][id_idx].clone(); let id_val = result.data.rows[0][id_idx].clone();
assert!(id_val.is_some_and(|s| !s.is_empty()), "generated shortid surfaced via RETURNING"); assert!(
id_val.is_some_and(|s| !s.is_empty()),
"generated shortid surfaced via RETURNING"
);
} }
#[test] #[test]
@@ -790,11 +926,29 @@ fn insert_returning_recovers_bare_column_type() {
// renders as the word, not 0/1). // renders as the word, not 0/1).
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]); create_cols(
let result = run_sqlinsert(&db, &rt, "insert into t (id, active) values (1, true) returning active") &db,
.expect("INSERT … RETURNING active runs"); &rt,
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered"); "t",
assert_eq!(result.data.rows[0][0], Some("true".to_string()), "rendered as the bool word"); &[("id", Type::Int), ("active", Type::Bool)],
&["id"],
);
let result = run_sqlinsert(
&db,
&rt,
"insert into t (id, active) values (1, true) returning active",
)
.expect("INSERT … RETURNING active runs");
assert_eq!(
result.data.column_types,
vec![Some(Type::Bool)],
"bool type recovered"
);
assert_eq!(
result.data.rows[0][0],
Some("true".to_string()),
"rendered as the bool word"
);
} }
#[test] #[test]
@@ -803,11 +957,29 @@ fn insert_returning_computed_expression_is_typeless() {
// so its recovered type is None (renders with neutral alignment). // so its recovered type is None (renders with neutral alignment).
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("n", Type::Int)], &["id"]); create_cols(
let result = run_sqlinsert(&db, &rt, "insert into t (id, n) values (1, 5) returning n + 1") &db,
.expect("INSERT … RETURNING <expr> runs"); &rt,
assert_eq!(result.data.column_types, vec![None], "computed projection is typeless"); "t",
assert_eq!(result.data.rows[0][0], Some("6".to_string()), "engine evaluated n + 1"); &[("id", Type::Int), ("n", Type::Int)],
&["id"],
);
let result = run_sqlinsert(
&db,
&rt,
"insert into t (id, n) values (1, 5) returning n + 1",
)
.expect("INSERT … RETURNING <expr> runs");
assert_eq!(
result.data.column_types,
vec![None],
"computed projection is typeless"
);
assert_eq!(
result.data.rows[0][0],
Some("6".to_string()),
"engine evaluated n + 1"
);
} }
#[test] #[test]
@@ -858,7 +1030,13 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() {
// rows each carrying its own generated id. // rows each carrying its own generated id.
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
&rt, &rt,
@@ -867,11 +1045,25 @@ fn multirow_autofill_returning_yields_distinct_generated_ids() {
.expect("multi-row auto-fill INSERT … RETURNING * runs"); .expect("multi-row auto-fill INSERT … RETURNING * runs");
assert_eq!(result.rows_affected, 3, "three rows inserted"); assert_eq!(result.rows_affected, 3, "three rows inserted");
assert_eq!(result.data.rows.len(), 3, "three rows returned"); assert_eq!(result.data.rows.len(), 3, "three rows returned");
let id_idx = result.data.columns.iter().position(|c| c == "id").expect("id column"); let id_idx = result
.data
.columns
.iter()
.position(|c| c == "id")
.expect("id column");
let ids: std::collections::BTreeSet<_> = let ids: std::collections::BTreeSet<_> =
result.data.rows.iter().map(|r| r[id_idx].clone()).collect(); result.data.rows.iter().map(|r| r[id_idx].clone()).collect();
assert_eq!(ids.len(), 3, "three DISTINCT generated ids via RETURNING: {:?}", result.data.rows); assert_eq!(
assert!(ids.iter().all(|v| v.as_ref().is_some_and(|s| !s.is_empty())), "all ids non-empty"); ids.len(),
3,
"three DISTINCT generated ids via RETURNING: {:?}",
result.data.rows
);
assert!(
ids.iter()
.all(|v| v.as_ref().is_some_and(|s| !s.is_empty())),
"all ids non-empty"
);
} }
#[test] #[test]
@@ -881,15 +1073,39 @@ fn insert_select_returning_executes_and_returns_rows() {
// source feeds the insert, and RETURNING yields the inserted rows). // source feeds the insert, and RETURNING yields the inserted rows).
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "src", &[("id", Type::Int), ("b", Type::Text)], &["id"]); create_cols(
create_cols(&db, &rt, "dst", &[("id", Type::Int), ("b", Type::Text)], &["id"]); &db,
run_sqlinsert(&db, &rt, "insert into src (id, b) values (1, 'x'), (2, 'y')").expect("seed src"); &rt,
let result = run_sqlinsert(&db, &rt, "insert into dst select * from src returning id, b") "src",
.expect("INSERT … SELECT … RETURNING runs"); &[("id", Type::Int), ("b", Type::Text)],
&["id"],
);
create_cols(
&db,
&rt,
"dst",
&[("id", Type::Int), ("b", Type::Text)],
&["id"],
);
run_sqlinsert(
&db,
&rt,
"insert into src (id, b) values (1, 'x'), (2, 'y')",
)
.expect("seed src");
let result = run_sqlinsert(
&db,
&rt,
"insert into dst select * from src returning id, b",
)
.expect("INSERT … SELECT … RETURNING runs");
assert_eq!(result.rows_affected, 2, "two rows copied"); assert_eq!(result.rows_affected, 2, "two rows copied");
assert_eq!(result.data.rows.len(), 2, "RETURNING yielded both inserted rows"); assert_eq!(
let bs: std::collections::BTreeSet<_> = result.data.rows.len(),
result.data.rows.iter().map(|r| r[1].clone()).collect(); 2,
"RETURNING yielded both inserted rows"
);
let bs: std::collections::BTreeSet<_> = result.data.rows.iter().map(|r| r[1].clone()).collect();
assert!(bs.contains(&Some("x".to_string())) && bs.contains(&Some("y".to_string()))); assert!(bs.contains(&Some("x".to_string())) && bs.contains(&Some("y".to_string())));
} }
@@ -934,32 +1150,59 @@ fn autofill_upsert_real_conflict_preserves_clause_and_excluded() {
"t".to_string(), "t".to_string(),
vec![ vec![
ColumnSpec::new("id", Type::ShortId), ColumnSpec::new("id", Type::ShortId),
ColumnSpec { unique: true, ..ColumnSpec::new("code", Type::Text) }, ColumnSpec {
unique: true,
..ColumnSpec::new("code", Type::Text)
},
ColumnSpec::new("label", Type::Text), ColumnSpec::new("label", Type::Text),
], ],
vec!["id".to_string()], vec!["id".to_string()],
None, None,
)) ))
.expect("create table with shortid pk + unique code"); .expect("create table with shortid pk + unique code");
run_sqlinsert(&db, &rt, "insert into t (code, label) values ('A', 'first')").expect("seed"); run_sqlinsert(
&db,
&rt,
"insert into t (code, label) values ('A', 'first')",
)
.expect("seed");
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
&rt, &rt,
"insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label", "insert into t (code, label) values ('A', 'second') on conflict (code) do update set label = excluded.label",
) )
.expect("auto-filled UPSERT with a real conflict (clause preserved)"); .expect("auto-filled UPSERT with a real conflict (clause preserved)");
assert_eq!(result.rows_affected, 1, "the conflicting row was updated, not inserted"); assert_eq!(
result.rows_affected, 1,
"the conflicting row was updated, not inserted"
);
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "still one row (DO UPDATE, not a second insert)"); assert_eq!(
assert!(rows[0].iter().any(|c| c == "second"), "label updated via excluded: {rows:?}"); rows.len(),
assert!(!rows[0].iter().any(|c| c == "first"), "old label replaced: {rows:?}"); 1,
"still one row (DO UPDATE, not a second insert)"
);
assert!(
rows[0].iter().any(|c| c == "second"),
"label updated via excluded: {rows:?}"
);
assert!(
!rows[0].iter().any(|c| c == "first"),
"old label replaced: {rows:?}"
);
} }
#[test] #[test]
fn on_conflict_do_nothing_keeps_existing_row() { fn on_conflict_do_nothing_keeps_existing_row() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("name", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed"); run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
@@ -970,14 +1213,23 @@ fn on_conflict_do_nothing_keeps_existing_row() {
assert_eq!(result.rows_affected, 0, "conflicting row left untouched"); assert_eq!(result.rows_affected, 0, "conflicting row left untouched");
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "still one row"); assert_eq!(rows.len(), 1, "still one row");
assert!(rows[0].iter().any(|c| c == "orig"), "original value kept: {rows:?}"); assert!(
rows[0].iter().any(|c| c == "orig"),
"original value kept: {rows:?}"
);
} }
#[test] #[test]
fn on_conflict_do_update_applies_excluded() { fn on_conflict_do_update_applies_excluded() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("name", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed"); run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
@@ -988,14 +1240,23 @@ fn on_conflict_do_update_applies_excluded() {
assert_eq!(result.rows_affected, 1, "the conflicting row was updated"); assert_eq!(result.rows_affected, 1, "the conflicting row was updated");
let rows = csv_rows(&project, "t"); let rows = csv_rows(&project, "t");
assert_eq!(rows.len(), 1, "still one row (updated, not inserted)"); assert_eq!(rows.len(), 1, "still one row (updated, not inserted)");
assert!(rows[0].iter().any(|c| c == "new"), "row updated to excluded.name: {rows:?}"); assert!(
rows[0].iter().any(|c| c == "new"),
"row updated to excluded.name: {rows:?}"
);
} }
#[test] #[test]
fn on_conflict_do_nothing_without_target() { fn on_conflict_do_nothing_without_target() {
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("name", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("name", Type::Text)],
&["id"],
);
run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed"); run_sqlinsert(&db, &rt, "insert into t (id, name) values (1, 'orig')").expect("seed");
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
@@ -1003,7 +1264,10 @@ fn on_conflict_do_nothing_without_target() {
"insert into t (id, name) values (1, 'x') on conflict do nothing", "insert into t (id, name) values (1, 'x') on conflict do nothing",
) )
.expect("ON CONFLICT (no target) DO NOTHING runs"); .expect("ON CONFLICT (no target) DO NOTHING runs");
assert_eq!(result.rows_affected, 0, "any-conflict do-nothing absorbed the duplicate"); assert_eq!(
result.rows_affected, 0,
"any-conflict do-nothing absorbed the duplicate"
);
} }
#[test] #[test]
@@ -1017,7 +1281,13 @@ fn autofill_preserves_on_conflict_clause() {
// the rewrite doesn't prepare-fail and the clause survives. // the rewrite doesn't prepare-fail and the clause survives.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::ShortId), ("label", Type::Text)],
&["id"],
);
let result = run_sqlinsert( let result = run_sqlinsert(
&db, &db,
&rt, &rt,
@@ -1054,7 +1324,10 @@ fn sql_dml_validates_literal_values_like_the_dsl() {
let dsl = r.block_on(db.insert( let dsl = r.block_on(db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "d".to_string()]), Some(vec!["id".to_string(), "d".to_string()]),
vec![Value::Number("1".to_string()), Value::Text("2025/01/15".to_string())], vec![
Value::Number("1".to_string()),
Value::Text("2025/01/15".to_string()),
],
Some("insert".to_string()), Some("insert".to_string()),
)); ));
assert!( assert!(
@@ -1187,20 +1460,34 @@ fn advanced_upsert_do_update_set_offers_typed_slot_hint() {
// column-typed slot hint (boundary-aware lookahead → typed slot). // column-typed slot hint (boundary-aware lookahead → typed slot).
let mut cache = SchemaCache::default(); let mut cache = SchemaCache::default();
let cols = vec![ let cols = vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, TableColumn {
TableColumn { name: "Name".to_string(), user_type: Type::Text, not_null: false, has_default: false }, 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,
},
]; ];
cache.tables.push("Customers".to_string()); cache.tables.push("Customers".to_string());
cache.columns.push("id".to_string()); cache.columns.push("id".to_string());
cache.columns.push("Name".to_string()); cache.columns.push("Name".to_string());
cache.table_columns.insert("Customers".to_string(), cols); cache.table_columns.insert("Customers".to_string(), cols);
let input = "insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name="; let input =
"insert into Customers (id, Name) values (1, 'x') on conflict (id) do update set Name=";
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced); let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced);
let Some(AmbientHint::Prose(prose)) = hint else { let Some(AmbientHint::Prose(prose)) = hint else {
panic!("expected a Prose hint at the UPSERT SET value slot, got {hint:?}"); panic!("expected a Prose hint at the UPSERT SET value slot, got {hint:?}");
}; };
assert!(prose.contains("Name"), "hint names the column `Name`: {prose:?}"); assert!(
prose.contains("Name"),
"hint names the column `Name`: {prose:?}"
);
assert!( assert!(
prose.contains("quoted string"), prose.contains("quoted string"),
"text-column hint says `quoted string`: {prose:?}" "text-column hint says `quoted string`: {prose:?}"
@@ -1251,8 +1538,14 @@ fn advanced_insert_form_a_value_offers_typed_slot_hint() {
// user-listed column, so the hint is that column's typed prose. // user-listed column, so the hint is that column's typed prose.
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]); let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
let prose = prose_at("insert into Things (note) values (", &schema); let prose = prose_at("insert into Things (note) values (", &schema);
assert!(prose.contains("note"), "names listed column `note`: {prose:?}"); assert!(
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}"); prose.contains("note"),
"names listed column `note`: {prose:?}"
);
assert!(
prose.contains("quoted string"),
"text-column prose: {prose:?}"
);
} }
#[test] #[test]
@@ -1271,8 +1564,14 @@ fn advanced_insert_second_position_hints_second_column() {
// hint is the SECOND column's typed prose. // hint is the SECOND column's typed prose.
let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]); let schema = vschema(&[("Things", &[("k", Type::Int), ("note", Type::Text)])]);
let prose = prose_at("insert into Things (k, note) values (5, ", &schema); let prose = prose_at("insert into Things (k, note) values (5, ", &schema);
assert!(prose.contains("note"), "second position names `note`: {prose:?}"); assert!(
assert!(prose.contains("quoted string"), "text-column prose: {prose:?}"); prose.contains("note"),
"second position names `note`: {prose:?}"
);
assert!(
prose.contains("quoted string"),
"text-column prose: {prose:?}"
);
} }
#[test] #[test]
@@ -1283,13 +1582,19 @@ fn advanced_insert_value_int_mismatch_is_caught_live() {
&schema, &schema,
Mode::Advanced, Mode::Advanced,
); );
assert!(!matches!(bad, InputState::Valid), "decimal into int rejected live: {bad:?}"); assert!(
!matches!(bad, InputState::Valid),
"decimal into int rejected live: {bad:?}"
);
let ok = classify_input_with_schema_in_mode( let ok = classify_input_with_schema_in_mode(
"insert into Things (k) values (5)", "insert into Things (k) values (5)",
&schema, &schema,
Mode::Advanced, Mode::Advanced,
); );
assert!(matches!(ok, InputState::Valid), "valid int literal parses: {ok:?}"); assert!(
matches!(ok, InputState::Valid),
"valid int literal parses: {ok:?}"
);
} }
#[test] #[test]
@@ -1303,7 +1608,10 @@ fn advanced_insert_string_into_int_is_caught_live() {
&schema, &schema,
Mode::Advanced, Mode::Advanced,
); );
assert!(!matches!(bad, InputState::Valid), "string into int rejected live: {bad:?}"); assert!(
!matches!(bad, InputState::Valid),
"string into int rejected live: {bad:?}"
);
} }
#[test] #[test]
@@ -1314,7 +1622,10 @@ fn advanced_insert_multi_row_typed_and_mismatch_caught() {
&schema, &schema,
Mode::Advanced, Mode::Advanced,
); );
assert!(matches!(ok, InputState::Valid), "well-formed multi-row parses: {ok:?}"); assert!(
matches!(ok, InputState::Valid),
"well-formed multi-row parses: {ok:?}"
);
let bad = classify_input_with_schema_in_mode( let bad = classify_input_with_schema_in_mode(
"insert into Things (k, note) values (1, 'a'), (3.14, 'b')", "insert into Things (k, note) values (1, 'a'), (3.14, 'b')",
&schema, &schema,
@@ -1333,14 +1644,21 @@ fn advanced_insert_form_b_maps_all_columns_including_serial() {
// takes an int literal (unlike the DSL, which omits auto-gen cols). // takes an int literal (unlike the DSL, which omits auto-gen cols).
let schema = vschema(&[( let schema = vschema(&[(
"Customers", "Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)], &[
("id", Type::Serial),
("Name", Type::Text),
("Email", Type::Text),
],
)]); )]);
let state = classify_input_with_schema_in_mode( let state = classify_input_with_schema_in_mode(
"insert into Customers values (1, 'Bob', 'b@c')", "insert into Customers values (1, 'Bob', 'b@c')",
&schema, &schema,
Mode::Advanced, Mode::Advanced,
); );
assert!(matches!(state, InputState::Valid), "Form B maps all 3 columns: {state:?}"); assert!(
matches!(state, InputState::Valid),
"Form B maps all 3 columns: {state:?}"
);
} }
#[test] #[test]
@@ -1356,6 +1674,9 @@ fn advanced_insert_value_expressions_still_parse_via_sql_expr() {
"insert into Things (k) values ((select 1))", "insert into Things (k) values ((select 1))",
] { ] {
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced); let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
assert!(matches!(state, InputState::Valid), "{input:?} must parse: {state:?}"); assert!(
matches!(state, InputState::Valid),
"{input:?} must parse: {state:?}"
);
} }
} }
+39 -59
View File
@@ -57,11 +57,13 @@ fn advanced_mode_select_dispatches_as_command_select() {
type_str(&mut app, "select 1"); type_str(&mut app, "select 1");
let actions = submit(&mut app); let actions = submit(&mut app);
match actions.as_slice() { match actions.as_slice() {
[Action::ExecuteDsl { [
command: Command::Select { sql }, Action::ExecuteDsl {
source, command: Command::Select { sql },
.. source,
}] => { ..
},
] => {
assert!( assert!(
sql.contains("select 1"), sql.contains("select 1"),
"Command::Select carries the validated SQL text: {sql:?}", "Command::Select carries the validated SQL text: {sql:?}",
@@ -118,10 +120,12 @@ fn colon_one_shot_from_simple_mode_dispatches_select() {
// Persistent mode is unchanged. // Persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple); assert_eq!(app.mode, Mode::Simple);
match actions.as_slice() { match actions.as_slice() {
[Action::ExecuteDsl { [
command: Command::Select { sql }, Action::ExecuteDsl {
.. command: Command::Select { sql },
}] => { ..
},
] => {
assert!( assert!(
sql.contains("select 1") && !sql.starts_with(':'), sql.contains("select 1") && !sql.starts_with(':'),
"the `:` is stripped before the SQL is queued: {sql:?}", "the `:` is stripped before the SQL is queued: {sql:?}",
@@ -167,8 +171,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -315,22 +318,12 @@ fn database_run_select_recovers_bool_column_type() {
) )
.await .await
.expect("create table"); .expect("create table");
db.insert( db.insert("Products".to_string(), None, vec![Value::Bool(true)], None)
"Products".to_string(), .await
None, .expect("insert row");
vec![Value::Bool(true)], db.insert("Products".to_string(), None, vec![Value::Bool(false)], None)
None, .await
) .expect("insert row");
.await
.expect("insert row");
db.insert(
"Products".to_string(),
None,
vec![Value::Bool(false)],
None,
)
.await
.expect("insert row");
}); });
let data = rt let data = rt
.block_on(db.run_select("select Active from Products".to_string())) .block_on(db.run_select("select Active from Products".to_string()))
@@ -370,9 +363,7 @@ fn database_run_select_recovers_text_type_through_alias() {
// origin metadata still points at `Users.Name`, so the // origin metadata still points at `Users.Name`, so the
// playground type is recovered. // playground type is recovered.
let data = rt let data = rt
.block_on( .block_on(db.run_select("select Name as n from Users".to_string()))
db.run_select("select Name as n from Users".to_string()),
)
.expect("SELECT runs"); .expect("SELECT runs");
assert_eq!(data.columns, vec!["n".to_string()]); assert_eq!(data.columns, vec!["n".to_string()]);
assert_eq!(data.column_types, vec![Some(Type::Text)]); assert_eq!(data.column_types, vec![Some(Type::Text)]);
@@ -394,9 +385,14 @@ fn database_run_select_computed_expression_stays_typeless() {
) )
.await .await
.expect("create table"); .expect("create table");
db.insert("T".to_string(), None, vec![Value::Number("5".to_string())], None) db.insert(
.await "T".to_string(),
.expect("insert"); None,
vec![Value::Number("5".to_string())],
None,
)
.await
.expect("insert");
}); });
let data = rt let data = rt
.block_on(db.run_select("select Score + 1 from T".to_string())) .block_on(db.run_select("select Score + 1 from T".to_string()))
@@ -434,17 +430,12 @@ fn engine_aggregate_in_where_routes_through_catalog() {
// ADR-0032 §11.4. Run the bad query and confirm the // ADR-0032 §11.4. Run the bad query and confirm the
// friendly layer routes the message through engine.aggregate_misuse. // friendly layer routes the message through engine.aggregate_misuse.
let err = rt let err = rt
.block_on(db.run_select( .block_on(db.run_select("select id from T where count(score) > 0".to_string()))
"select id from T where count(score) > 0".to_string(),
))
.expect_err("engine should reject aggregate in WHERE"); .expect_err("engine should reject aggregate in WHERE");
let DbError::Sqlite { .. } = &err else { let DbError::Sqlite { .. } = &err else {
panic!("expected Sqlite engine error; got {err:?}"); panic!("expected Sqlite engine error; got {err:?}");
}; };
let friendly = friendly::translate_error( let friendly = friendly::translate_error(&err, &friendly::TranslateContext::default());
&err,
&friendly::TranslateContext::default(),
);
let rendered = friendly.render(); let rendered = friendly.render();
assert!( assert!(
rendered.contains("aggregate"), rendered.contains("aggregate"),
@@ -506,23 +497,17 @@ fn engine_group_by_missing_routes_through_catalog() {
// false-positive on phrasings that happen to contain // false-positive on phrasings that happen to contain
// "group by" elsewhere. Any successful query is fine. // "group by" elsewhere. Any successful query is fine.
let _ = rt let _ = rt
.block_on(db.run_select( .block_on(db.run_select("select category, count(*) from T group by category".to_string()))
"select category, count(*) from T group by category".to_string(),
))
.expect("benign GROUP BY query runs"); .expect("benign GROUP BY query runs");
// Direct unit test on the matcher: ensure a message that // Direct unit test on the matcher: ensure a message that
// mentions GROUP BY routes through the catalog. // mentions GROUP BY routes through the catalog.
let synthetic = DbError::Sqlite { let synthetic = DbError::Sqlite {
message: message: "column must appear in the GROUP BY clause or be used in an aggregate function"
"column must appear in the GROUP BY clause or be used in an aggregate function" .to_string(),
.to_string(),
kind: rdbms_playground::db::SqliteErrorKind::Other, kind: rdbms_playground::db::SqliteErrorKind::Other,
}; };
let rendered = friendly::translate_error( let rendered =
&synthetic, friendly::translate_error(&synthetic, &friendly::TranslateContext::default()).render();
&friendly::TranslateContext::default(),
)
.render();
assert!( assert!(
rendered.contains("GROUP BY"), rendered.contains("GROUP BY"),
"engine.group_by_required wording missing; got {rendered:?}", "engine.group_by_required wording missing; got {rendered:?}",
@@ -567,19 +552,14 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() {
// catalog (the matcher would fire if SQLite ever // catalog (the matcher would fire if SQLite ever
// surfaced this verbatim). // surfaced this verbatim).
let _ = rt let _ = rt
.block_on(db.run_select( .block_on(db.run_select("select (select v from T) from T".to_string()))
"select (select v from T) from T".to_string(),
))
.expect("benign scalar subquery query runs"); .expect("benign scalar subquery query runs");
let synthetic = DbError::Sqlite { let synthetic = DbError::Sqlite {
message: "scalar subquery returned more than one row".to_string(), message: "scalar subquery returned more than one row".to_string(),
kind: rdbms_playground::db::SqliteErrorKind::Other, kind: rdbms_playground::db::SqliteErrorKind::Other,
}; };
let rendered = friendly::translate_error( let rendered =
&synthetic, friendly::translate_error(&synthetic, &friendly::TranslateContext::default()).render();
&friendly::TranslateContext::default(),
)
.render();
assert!( assert!(
rendered.contains("more than one row"), rendered.contains("more than one row"),
"engine.scalar_subquery_too_many_rows wording missing; got {rendered:?}", "engine.scalar_subquery_too_many_rows wording missing; got {rendered:?}",
+196 -51
View File
@@ -28,8 +28,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir"); let dir = tempfile::tempdir().expect("create tempdir");
let project = let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf()); let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence) let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence"); .expect("open db with persistence");
@@ -77,15 +76,18 @@ fn run_update(
input: &str, input: &str,
) -> Result<UpdateResult, DbError> { ) -> Result<UpdateResult, DbError> {
match parse_command(input).expect("parse update") { match parse_command(input).expect("parse update") {
Command::SqlUpdate { sql, target_table, returning, set_literals } => rt.block_on( Command::SqlUpdate {
db.run_sql_update_with_literals( sql,
sql, target_table,
Some(input.to_string()), returning,
target_table, set_literals,
returning, } => rt.block_on(db.run_sql_update_with_literals(
set_literals, sql,
), Some(input.to_string()),
), target_table,
returning,
set_literals,
)),
other => panic!("expected Command::SqlUpdate, got {other:?}"), other => panic!("expected Command::SqlUpdate, got {other:?}"),
} }
} }
@@ -95,7 +97,9 @@ fn parse_path_lowers_sql_update_to_command() {
let command = parse_command("update Orders set total = 0 where id = 1") let command = parse_command("update Orders set total = 0 where id = 1")
.expect("update parses in advanced mode"); .expect("update parses in advanced mode");
match command { match command {
Command::SqlUpdate { sql, target_table, .. } => { Command::SqlUpdate {
sql, target_table, ..
} => {
assert_eq!(sql, "update Orders set total = 0 where id = 1"); assert_eq!(sql, "update Orders set total = 0 where id = 1");
assert_eq!(target_table, "Orders"); assert_eq!(target_table, "Orders");
} }
@@ -107,10 +111,20 @@ fn parse_path_lowers_sql_update_to_command() {
fn single_column_update_with_where_persists() { fn single_column_update_with_where_persists() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t"); &db,
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1") &rt,
.expect("update runs"); "t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, v) values (1, 'old'), (2, 'keep')",
"t",
);
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1").expect("update runs");
assert_eq!(result.rows_affected, 1, "one row updated"); assert_eq!(result.rows_affected, 1, "one row updated");
let csv = read_csv(&project, "t").expect("t.csv"); let csv = read_csv(&project, "t").expect("t.csv");
assert!(csv.contains("new"), "updated value present: {csv:?}"); assert!(csv.contains("new"), "updated value present: {csv:?}");
@@ -134,7 +148,10 @@ fn multi_column_update_persists() {
.expect("multi-col update runs"); .expect("multi-col update runs");
assert_eq!(result.rows_affected, 1); assert_eq!(result.rows_affected, 1);
let csv = read_csv(&project, "t").expect("t.csv"); let csv = read_csv(&project, "t").expect("t.csv");
assert!(csv.contains('9') && csv.contains('y'), "both columns updated: {csv:?}"); assert!(
csv.contains('9') && csv.contains('y'),
"both columns updated: {csv:?}"
);
} }
#[test] #[test]
@@ -142,10 +159,21 @@ fn update_without_where_runs_across_all_rows() {
// ADR-0030 §12: no `--all-rows` rail. // ADR-0030 §12: no `--all-rows` rail.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, active) values (1, true), (2, true)", "t"); &db,
let result = run_update(&db, &rt, "update t set active = false") &rt,
.expect("unfiltered update runs"); "t",
&[("id", Type::Int), ("active", Type::Bool)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, active) values (1, true), (2, true)",
"t",
);
let result =
run_update(&db, &rt, "update t set active = false").expect("unfiltered update runs");
assert_eq!(result.rows_affected, 2, "all rows updated"); assert_eq!(result.rows_affected, 2, "all rows updated");
let csv = read_csv(&project, "t").expect("t.csv"); let csv = read_csv(&project, "t").expect("t.csv");
assert!(!csv.contains("true"), "no row left active: {csv:?}"); assert!(!csv.contains("true"), "no row left active: {csv:?}");
@@ -159,10 +187,20 @@ fn update_with_sql_expr_in_set() {
&db, &db,
&rt, &rt,
"t", "t",
&[("id", Type::Int), ("price", Type::Int), ("qty", Type::Int), ("total", Type::Int)], &[
("id", Type::Int),
("price", Type::Int),
("qty", Type::Int),
("total", Type::Int),
],
&["id"], &["id"],
); );
seed(&db, &rt, "insert into t (id, price, qty, total) values (1, 6, 7, 0)", "t"); seed(
&db,
&rt,
"insert into t (id, price, qty, total) values (1, 6, 7, 0)",
"t",
);
let result = run_update(&db, &rt, "update t set total = price * qty where id = 1") let result = run_update(&db, &rt, "update t set total = price * qty where id = 1")
.expect("expression update runs"); .expect("expression update runs");
assert_eq!(result.rows_affected, 1); assert_eq!(result.rows_affected, 1);
@@ -176,8 +214,19 @@ fn update_with_subquery_in_set() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "other", &[("n", Type::Int)], &["n"]); create_cols(&db, &rt, "other", &[("n", Type::Int)], &["n"]);
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]); create_cols(
seed(&db, &rt, "insert into other (n) values (3), (8), (5)", "other"); &db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Int)],
&["id"],
);
seed(
&db,
&rt,
"insert into other (n) values (3), (8), (5)",
"other",
);
seed(&db, &rt, "insert into t (id, v) values (1, 0)", "t"); seed(&db, &rt, "insert into t (id, v) values (1, 0)", "t");
let result = run_update( let result = run_update(
&db, &db,
@@ -196,13 +245,22 @@ fn update_matching_no_rows_is_ok() {
// the path doesn't crash, and the CSV is unchanged. // the path doesn't crash, and the CSV is unchanged.
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t"); seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999") let result = run_update(&db, &rt, "update t set v = 'x' where id = 999")
.expect("no-match update is a success"); .expect("no-match update is a success");
assert_eq!(result.rows_affected, 0, "no rows matched"); assert_eq!(result.rows_affected, 0, "no rows matched");
let csv = read_csv(&project, "t").expect("t.csv"); let csv = read_csv(&project, "t").expect("t.csv");
assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}"); assert!(
csv.contains("keep") && !csv.contains('x'),
"unchanged: {csv:?}"
);
} }
// ================================================================= // =================================================================
@@ -220,8 +278,19 @@ fn sql_update_validates_set_literals_like_the_dsl() {
// STRICT TEXT column accept anything). // STRICT TEXT column accept anything).
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("d", Type::Date)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, d) values (1, '2025-01-15')", "t"); &db,
&rt,
"t",
&[("id", Type::Int), ("d", Type::Date)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, d) values (1, '2025-01-15')",
"t",
);
// SQL path (advanced mode, full replay pipeline) — REJECTS the bad date. // SQL path (advanced mode, full replay pipeline) — REJECTS the bad date.
std::fs::write( std::fs::write(
@@ -300,7 +369,12 @@ fn sql_update_validates_every_assignment_not_just_the_first() {
&[("id", Type::Int), ("v", Type::Text), ("d", Type::Date)], &[("id", Type::Int), ("v", Type::Text), ("d", Type::Date)],
&["id"], &["id"],
); );
seed(&db, &rt, "insert into t (id, v, d) values (1, 'a', '2025-01-01')", "t"); seed(
&db,
&rt,
"insert into t (id, v, d) values (1, 'a', '2025-01-01')",
"t",
);
std::fs::write( std::fs::write(
project.path().join("multi.commands"), project.path().join("multi.commands"),
"update t set v = 'ok', d = '2025/01/15' where id = 1\n", "update t set v = 'ok', d = '2025/01/15' where id = 1\n",
@@ -321,26 +395,64 @@ fn sql_update_validates_every_assignment_not_just_the_first() {
fn update_returning_yields_modified_columns() { fn update_returning_yields_modified_columns() {
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, v) values (1, 'old'), (2, 'keep')", "t"); &db,
let result = run_update(&db, &rt, "update t set v = 'new' where id = 1 returning id, v") &rt,
.expect("UPDATE … RETURNING runs"); "t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, v) values (1, 'old'), (2, 'keep')",
"t",
);
let result = run_update(
&db,
&rt,
"update t set v = 'new' where id = 1 returning id, v",
)
.expect("UPDATE … RETURNING runs");
assert_eq!(result.rows_affected, 1, "one row updated"); assert_eq!(result.rows_affected, 1, "one row updated");
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]); assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()]);
assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows.len(), 1);
// RETURNING reflects the POST-update value. // RETURNING reflects the POST-update value.
assert_eq!(result.data.rows[0][1], Some("new".to_string()), "modified value returned"); assert_eq!(
result.data.rows[0][1],
Some("new".to_string()),
"modified value returned"
);
} }
#[test] #[test]
fn update_returning_recovers_bare_column_type() { fn update_returning_recovers_bare_column_type() {
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("active", Type::Bool)], &["id"]); create_cols(
seed(&db, &rt, "insert into t (id, active) values (1, false)", "t"); &db,
let result = run_update(&db, &rt, "update t set active = true where id = 1 returning active") &rt,
.expect("UPDATE … RETURNING active runs"); "t",
assert_eq!(result.data.column_types, vec![Some(Type::Bool)], "bool type recovered"); &[("id", Type::Int), ("active", Type::Bool)],
&["id"],
);
seed(
&db,
&rt,
"insert into t (id, active) values (1, false)",
"t",
);
let result = run_update(
&db,
&rt,
"update t set active = true where id = 1 returning active",
)
.expect("UPDATE … RETURNING active runs");
assert_eq!(
result.data.column_types,
vec![Some(Type::Bool)],
"bool type recovered"
);
assert_eq!(result.data.rows[0][0], Some("true".to_string())); assert_eq!(result.data.rows[0][0], Some("true".to_string()));
} }
@@ -352,13 +464,27 @@ fn update_returning_matching_no_rows_is_ok_and_empty() {
// phantom row. // phantom row.
let (_project, db, _dir) = open_project_db(); let (_project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Text)],
&["id"],
);
seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t"); seed(&db, &rt, "insert into t (id, v) values (1, 'keep')", "t");
let result = run_update(&db, &rt, "update t set v = 'x' where id = 999 returning id, v") let result = run_update(
.expect("no-match UPDATE … RETURNING is a success"); &db,
&rt,
"update t set v = 'x' where id = 999 returning id, v",
)
.expect("no-match UPDATE … RETURNING is a success");
assert_eq!(result.rows_affected, 0, "no rows matched"); assert_eq!(result.rows_affected, 0, "no rows matched");
assert!(result.data.rows.is_empty(), "no rows returned"); assert!(result.data.rows.is_empty(), "no rows returned");
assert_eq!(result.data.columns, vec!["id".to_string(), "v".to_string()], "columns still present"); assert_eq!(
result.data.columns,
vec!["id".to_string(), "v".to_string()],
"columns still present"
);
} }
// ================================================================= // =================================================================
@@ -399,14 +525,21 @@ fn advanced_update_set_value_offers_typed_slot_hint_for_column() {
// instead of the type-blind sql_expr surface. // instead of the type-blind sql_expr surface.
let schema = schema_cache(&[( let schema = schema_cache(&[(
"Customers", "Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)], &[
("id", Type::Serial),
("Name", Type::Text),
("Email", Type::Text),
],
)]); )]);
let input = "update Customers set Email="; let input = "update Customers set Email=";
let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced); let hint = ambient_hint_in_mode(input, input.len(), None, &schema, Mode::Advanced);
let Some(AmbientHint::Prose(prose)) = hint else { let Some(AmbientHint::Prose(prose)) = hint else {
panic!("expected a Prose hint at the typed value slot, got {hint:?}"); panic!("expected a Prose hint at the typed value slot, got {hint:?}");
}; };
assert!(prose.contains("Email"), "hint names the column `Email`: {prose:?}"); assert!(
prose.contains("Email"),
"hint names the column `Email`: {prose:?}"
);
assert!( assert!(
prose.contains("quoted string"), prose.contains("quoted string"),
"text-column hint says `quoted string`: {prose:?}" "text-column hint says `quoted string`: {prose:?}"
@@ -449,7 +582,10 @@ fn advanced_update_set_int_value_type_mismatch_is_caught_live() {
&schema, &schema,
Mode::Advanced, Mode::Advanced,
); );
assert!(matches!(ok, InputState::Valid), "a valid int literal parses: {ok:?}"); assert!(
matches!(ok, InputState::Valid),
"a valid int literal parses: {ok:?}"
);
} }
#[test] #[test]
@@ -463,10 +599,10 @@ fn advanced_update_set_expression_still_parses_via_sql_expr() {
("other", &[("n", Type::Int)]), ("other", &[("n", Type::Int)]),
]); ]);
for input in [ for input in [
"update Things set k = 3 + 2 where k = 0", // literal-prefixed expression "update Things set k = 3 + 2 where k = 0", // literal-prefixed expression
"update Things set k = (select max(n) from other) where k = 0", // scalar subquery "update Things set k = (select max(n) from other) where k = 0", // scalar subquery
"update Things set note = upper(note) where k = 0", // function call "update Things set note = upper(note) where k = 0", // function call
"update Things set k = -5 where k = 0", // signed number → sql_expr "update Things set k = -5 where k = 0", // signed number → sql_expr
] { ] {
let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced); let state = classify_input_with_schema_in_mode(input, &schema, Mode::Advanced);
assert!( assert!(
@@ -488,7 +624,13 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
// parse-level dispatch (covered in tests/sql_dml_e2e.rs). // parse-level dispatch (covered in tests/sql_dml_e2e.rs).
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
let rt = rt(); let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Int)], &["id"]); create_cols(
&db,
&rt,
"t",
&[("id", Type::Int), ("v", Type::Int)],
&["id"],
);
seed(&db, &rt, "insert into t (id, v) values (1, 1), (2, 2)", "t"); seed(&db, &rt, "insert into t (id, v) values (1, 1), (2, 2)", "t");
std::fs::write( std::fs::write(
project.path().join("allrows.commands"), project.path().join("allrows.commands"),
@@ -497,7 +639,10 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
.expect("write script"); .expect("write script");
let events = rt.block_on(run_replay(&db, project.path(), "allrows.commands")); let events = rt.block_on(run_replay(&db, project.path(), "allrows.commands"));
assert!( assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 1, .. })), matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 1, .. })
),
"the --all-rows update replays through the DSL fall-back; events: {events:?}" "the --all-rows update replays through the DSL fall-back; events: {events:?}"
); );
let rows = rt let rows = rt
+10 -4
View File
@@ -179,7 +179,10 @@ fn undo_disabled_takes_no_snapshots() {
.unwrap(); .unwrap();
// Nothing to undo, and no snapshot machinery on disk. // Nothing to undo, and no snapshot machinery on disk.
assert!(db.undo().await.unwrap().is_none(), "undo is a no-op when disabled"); assert!(
db.undo().await.unwrap().is_none(),
"undo is a no-op when disabled"
);
assert!(db.peek_undo().await.unwrap().is_none()); assert!(db.peek_undo().await.unwrap().is_none());
}); });
@@ -360,7 +363,10 @@ fn undo_restores_db_and_csv_consistently() {
db.insert( db.insert(
"T".to_string(), "T".to_string(),
Some(vec!["id".to_string(), "name".to_string()]), Some(vec!["id".to_string(), "name".to_string()]),
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())], vec![
Value::Number("1".to_string()),
Value::Text("Alice".to_string()),
],
Some("insert Alice".to_string()), Some("insert Alice".to_string()),
) )
.await .await
@@ -444,8 +450,8 @@ fn undo_ring_persists_across_reopen() {
// held by `project`). The persisted ring must survive. // held by `project`). The persisted ring must survive.
drop(db); drop(db);
let persistence = Persistence::new(path); let persistence = Persistence::new(path);
let db2 = Database::open_with_persistence_and_undo(&db_path, persistence, true) let db2 =
.expect("reopen db"); Database::open_with_persistence_and_undo(&db_path, persistence, true).expect("reopen db");
rt().block_on(async { rt().block_on(async {
let peek = db2 let peek = db2
+57 -22
View File
@@ -49,7 +49,11 @@ fn submit(app: &mut App) -> Vec<Action> {
/// and don't care about the verbatim user input. /// and don't care about the verbatim user input.
#[track_caller] #[track_caller]
fn assert_one_execute_dsl(actions: &[Action], expected: &Command) { fn assert_one_execute_dsl(actions: &[Action], expected: &Command) {
assert_eq!(actions.len(), 1, "expected exactly one action; got {actions:?}"); assert_eq!(
actions.len(),
1,
"expected exactly one action; got {actions:?}"
);
match &actions[0] { match &actions[0] {
Action::ExecuteDsl { command, .. } => assert_eq!(command, expected), Action::ExecuteDsl { command, .. } => assert_eq!(command, expected),
other => panic!("expected ExecuteDsl, got {other:?}"), other => panic!("expected ExecuteDsl, got {other:?}"),
@@ -234,9 +238,18 @@ fn status_bar_is_keystroke_only_and_state_aware() {
// Default (empty input): nav / complete / history / run keystrokes. // Default (empty input): nav / complete / history / run keystrokes.
let default_view = rendered_text(&mut app, &theme, 80, 24); let default_view = rendered_text(&mut app, &theme, 80, 24);
assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}"); assert!(
assert!(default_view.contains("Enter run"), "strip lists run:\n{default_view}"); default_view.contains("Ctrl-O sidebar"),
assert!(!default_view.contains("Ctrl-C"), "quit dropped from the strip:\n{default_view}"); "strip lists sidebar:\n{default_view}"
);
assert!(
default_view.contains("Enter run"),
"strip lists run:\n{default_view}"
);
assert!(
!default_view.contains("Ctrl-C"),
"quit dropped from the strip:\n{default_view}"
);
assert!( assert!(
!default_view.contains("advanced once"), !default_view.contains("advanced once"),
"`:` command word dropped from the strip:\n{default_view}", "`:` command word dropped from the strip:\n{default_view}",
@@ -245,8 +258,14 @@ fn status_bar_is_keystroke_only_and_state_aware() {
// Editing (input has text): the #29 readline edit keys appear. // Editing (input has text): the #29 readline edit keys appear.
type_str(&mut app, "create"); type_str(&mut app, "create");
let editing = rendered_text(&mut app, &theme, 80, 24); let editing = rendered_text(&mut app, &theme, 80, 24);
assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}"); assert!(
assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}"); editing.contains("Esc clear"),
"editing strip lists clear:\n{editing}"
);
assert!(
editing.contains("Ctrl-W del word"),
"editing strip lists del word:\n{editing}"
);
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -326,7 +345,9 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
// `id` row shows both the name and its `serial` type // `id` row shows both the name and its `serial` type
// separated by box-drawing characters. // separated by box-drawing characters.
assert!( assert!(
rendered.lines().any(|l| l.contains("id") && l.contains("serial")), rendered
.lines()
.any(|l| l.contains("id") && l.contains("serial")),
"output should show the id/serial column row:\n{rendered}" "output should show the id/serial column row:\n{rendered}"
); );
} }
@@ -336,10 +357,7 @@ fn add_column_flow_updates_structure_view() {
let mut app = App::new(); let mut app = App::new();
// Simulate the prior create_table state. // Simulate the prior create_table state.
app.tables = vec!["Customers".to_string()]; app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table( app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));
"Customers",
&[("id", Type::Serial, true)],
));
type_str(&mut app, "add column to table Customers: Name (text)"); type_str(&mut app, "add column to table Customers: Name (text)");
let actions = submit(&mut app); let actions = submit(&mut app);
@@ -376,7 +394,9 @@ fn add_column_flow_updates_structure_view() {
assert_eq!(app.current_table, Some(updated)); assert_eq!(app.current_table, Some(updated));
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!( assert!(
rendered.lines().any(|l| l.contains("Name") && l.contains("text")), rendered
.lines()
.any(|l| l.contains("Name") && l.contains("text")),
"expected the Name/text column row:\n{rendered}", "expected the Name/text column row:\n{rendered}",
); );
} }
@@ -496,10 +516,7 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
assert!(rendered.contains("on delete cascade"), "{rendered}"); assert!(rendered.contains("on delete cascade"), "{rendered}");
// The [ok] subject lists the endpoints. Long lines wrap in // The [ok] subject lists the endpoints. Long lines wrap in
// the panel, so we check the first half of the phrase only. // the panel, so we check the first half of the phrase only.
assert!( assert!(rendered.contains("from Customers.Id"), "{rendered}");
rendered.contains("from Customers.Id"),
"{rendered}"
);
} }
#[test] #[test]
@@ -551,11 +568,23 @@ fn add_column_confirmation_omits_relationship_prose() {
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
// The structure box still renders (table name + the column box from // The structure box still renders (table name + the column box from
// the returned description). // the returned description).
assert!(rendered.contains("Customers"), "structure header:\n{rendered}"); assert!(
assert!(rendered.contains("Constraints"), "structure box:\n{rendered}"); rendered.contains("Customers"),
"structure header:\n{rendered}"
);
assert!(
rendered.contains("Constraints"),
"structure box:\n{rendered}"
);
// The relationship block is gone — neither prose heading nor line. // The relationship block is gone — neither prose heading nor line.
assert!(!rendered.contains("Referenced by:"), "no prose heading:\n{rendered}"); assert!(
assert!(!rendered.contains("References:"), "no prose heading:\n{rendered}"); !rendered.contains("Referenced by:"),
"no prose heading:\n{rendered}"
);
assert!(
!rendered.contains("References:"),
"no prose heading:\n{rendered}"
);
assert!( assert!(
!rendered.contains("Orders.CustId → Id"), !rendered.contains("Orders.CustId → Id"),
"no prose line:\n{rendered}", "no prose line:\n{rendered}",
@@ -682,8 +711,14 @@ fn validity_indicator_renders_err_and_wrn_labels() {
let mut app = App::new(); let mut app = App::new();
let clean = rendered_text(&mut app, &Theme::dark(), 80, 24); let clean = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(!clean.contains("[ERR]"), "clean input shows no label:\n{clean}"); assert!(
assert!(!clean.contains("[WRN]"), "clean input shows no label:\n{clean}"); !clean.contains("[ERR]"),
"clean input shows no label:\n{clean}"
);
assert!(
!clean.contains("[WRN]"),
"clean input shows no label:\n{clean}"
);
app.input_indicator = Some(Severity::Error); app.input_indicator = Some(Severity::Error);
let err = rendered_text(&mut app, &Theme::dark(), 80, 24); let err = rendered_text(&mut app, &Theme::dark(), 80, 24);
+4 -16
View File
@@ -28,10 +28,7 @@ fn one_n_relationship_keyword_sequence_is_incomplete() {
#[test] #[test]
fn after_from_offers_table_names() { fn after_from_offers_table_names() {
let schema = schema_multi_table(); let schema = schema_multi_table();
let a = assess_at_end( let a = assess_at_end("add 1:n relationship from ", &schema);
"add 1:n relationship from ",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]); assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_from", a); crate::snap!("after_from", a);
@@ -41,10 +38,7 @@ fn after_from_offers_table_names() {
fn after_parent_table_dot_narrows_to_parent_columns() { fn after_parent_table_dot_narrows_to_parent_columns() {
// §2.2 follow-up — `from Customers.` narrows to Customers. // §2.2 follow-up — `from Customers.` narrows to Customers.
let schema = schema_multi_table(); let schema = schema_multi_table();
let a = assess_at_end( let a = assess_at_end("add 1:n relationship from Customers.", &schema);
"add 1:n relationship from Customers.",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["id", "Name"]); assert_candidate_present(&a, &["id", "Name"]);
assert_no_candidate_named(&a, &["OrderId", "CustId", "Total"]); assert_no_candidate_named(&a, &["OrderId", "CustId", "Total"]);
@@ -54,10 +48,7 @@ fn after_parent_table_dot_narrows_to_parent_columns() {
#[test] #[test]
fn after_child_table_dot_narrows_to_child_columns() { fn after_child_table_dot_narrows_to_child_columns() {
let schema = schema_multi_table(); let schema = schema_multi_table();
let a = assess_at_end( let a = assess_at_end("add 1:n relationship from Customers.id to Orders.", &schema);
"add 1:n relationship from Customers.id to Orders.",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["OrderId", "CustId", "Total"]); assert_candidate_present(&a, &["OrderId", "CustId", "Total"]);
assert_no_candidate_named(&a, &["Name"]); assert_no_candidate_named(&a, &["Name"]);
@@ -101,10 +92,7 @@ fn add_relationship_with_on_delete_clause_parses() {
#[test] #[test]
fn add_relationship_in_progress_after_dot_is_incomplete() { fn add_relationship_in_progress_after_dot_is_incomplete() {
let schema = schema_multi_table(); let schema = schema_multi_table();
let a = assess_at_end( let a = assess_at_end("add 1:n relationship from Customers.id to ", &schema);
"add 1:n relationship from Customers.id to ",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("in_progress_after_to", a); crate::snap!("in_progress_after_to", a);
} }
+4 -1
View File
@@ -67,7 +67,10 @@ fn create_m2n_with_as_name_parses() {
#[test] #[test]
fn after_as_keyword_is_incomplete() { fn after_as_keyword_is_incomplete() {
let schema = schema_multi_table(); let schema = schema_multi_table();
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema); let a = assess_at_end(
"create m:n relationship from Customers to Orders as ",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("after_as", a); crate::snap!("after_as", a);
} }
+3 -12
View File
@@ -83,25 +83,16 @@ fn after_pk_space_with_col_name_typed_expects_paren() {
#[test] #[test]
fn after_paren_expects_type_candidates() { fn after_paren_expects_type_candidates() {
let schema = schema_empty(); let schema = schema_empty();
let a = assess_at_end( let a = assess_at_end("create table Customers with pk Code(", &schema);
"create table Customers with pk Code(",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present( assert_candidate_present(&a, &["text", "int", "serial", "shortid", "bool"]);
&a,
&["text", "int", "serial", "shortid", "bool"],
);
crate::snap!("after_paren", a); crate::snap!("after_paren", a);
} }
#[test] #[test]
fn create_table_with_explicit_pk_parses() { fn create_table_with_explicit_pk_parses() {
let schema = schema_empty(); let schema = schema_empty();
let a = assess_at_end( let a = assess_at_end("create table Customers with pk Code(text)", &schema);
"create table Customers with pk Code(text)",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
crate::snap!("with_explicit_pk", a); crate::snap!("with_explicit_pk", a);
} }
+4 -14
View File
@@ -42,13 +42,8 @@ fn after_where_offers_active_table_columns_no_leakage() {
#[test] #[test]
fn after_where_column_equals_offers_typed_prose() { fn after_where_column_equals_offers_typed_prose() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("delete from Customers where Email=", &schema);
"delete from Customers where Email=", let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
&schema,
);
let prose = hint_prose(&a).unwrap_or_else(|| {
panic!("expected Prose, got {:?}", a.hint)
});
assert!( assert!(
prose.contains("Email"), prose.contains("Email"),
"should name `Email`, got prose: {prose:?}", "should name `Email`, got prose: {prose:?}",
@@ -63,10 +58,7 @@ fn after_where_column_equals_offers_typed_prose() {
#[test] #[test]
fn complete_delete_with_where_parses() { fn complete_delete_with_where_parses() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("delete from Customers where id=1", &schema);
"delete from Customers where id=1",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("Delete")); assert_eq!(a.parse_result.as_deref(), Ok("Delete"));
crate::snap!("complete_delete", a); crate::snap!("complete_delete", a);
@@ -76,9 +68,7 @@ fn complete_delete_with_where_parses() {
fn delete_with_datetime_column_says_yyyy_mm_dd_t() { fn delete_with_datetime_column_says_yyyy_mm_dd_t() {
let schema = schema_every_type(); let schema = schema_every_type();
let a = assess_at_end("delete from Things where ts=", &schema); let a = assess_at_end("delete from Things where ts=", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
panic!("expected Prose, got {:?}", a.hint)
});
assert!( assert!(
prose.contains("ts"), prose.contains("ts"),
"should name `ts`, got prose: {prose:?}", "should name `ts`, got prose: {prose:?}",
+1 -4
View File
@@ -63,10 +63,7 @@ fn complete_drop_column_parses() {
#[test] #[test]
fn drop_column_with_table_keyword_parses() { fn drop_column_with_table_keyword_parses() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("drop column from table Customers: Email", &schema);
"drop column from table Customers: Email",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
crate::snap!("with_table_keyword", a); crate::snap!("with_table_keyword", a);
} }
+3 -12
View File
@@ -30,10 +30,7 @@ fn after_relationship_keyword_offers_from_and_names() {
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
// Both selector forms discoverable: `from` for endpoints, // Both selector forms discoverable: `from` for endpoints,
// the relationship name for the by-name form. // the relationship name for the by-name form.
assert_candidate_present( assert_candidate_present(&a, &["from", "Orders_CustId_to_Customers"]);
&a,
&["from", "Orders_CustId_to_Customers"],
);
crate::snap!("after_relationship_keyword", a); crate::snap!("after_relationship_keyword", a);
} }
@@ -61,10 +58,7 @@ fn after_parent_table_dot_narrows_to_parent_columns() {
#[test] #[test]
fn after_to_offers_table_names() { fn after_to_offers_table_names() {
let schema = schema_with_relationship(); let schema = schema_with_relationship();
let a = assess_at_end( let a = assess_at_end("drop relationship from Orders.CustId to ", &schema);
"drop relationship from Orders.CustId to ",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["Customers", "Orders"]); assert_candidate_present(&a, &["Customers", "Orders"]);
crate::snap!("after_to", a); crate::snap!("after_to", a);
@@ -98,10 +92,7 @@ fn complete_drop_relationship_by_endpoints_parses() {
#[test] #[test]
fn complete_drop_relationship_by_name_parses() { fn complete_drop_relationship_by_name_parses() {
let schema = schema_with_relationship(); let schema = schema_with_relationship();
let a = assess_at_end( let a = assess_at_end("drop relationship Orders_CustId_to_Customers", &schema);
"drop relationship Orders_CustId_to_Customers",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("DropRelationship")); assert_eq!(a.parse_result.as_deref(), Ok("DropRelationship"));
crate::snap!("complete_by_name", a); crate::snap!("complete_by_name", a);
+2 -8
View File
@@ -66,10 +66,7 @@ fn complete_explain_show_data_parses_as_explain() {
#[test] #[test]
fn complete_explain_show_data_with_where_and_limit_parses() { fn complete_explain_show_data_with_where_and_limit_parses() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("explain show data Customers where id = 1 limit 5", &schema);
"explain show data Customers where id = 1 limit 5",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("Explain")); assert_eq!(a.parse_result.as_deref(), Ok("Explain"));
crate::snap!("complete_explain_show_data_where_limit", a); crate::snap!("complete_explain_show_data_where_limit", a);
@@ -95,10 +92,7 @@ fn after_explain_update_table_expects_set() {
#[test] #[test]
fn complete_explain_update_parses_as_explain() { fn complete_explain_update_parses_as_explain() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("explain update Customers set Name='Bo' where id=1", &schema);
"explain update Customers set Name='Bo' where id=1",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("Explain")); assert_eq!(a.parse_result.as_deref(), Ok("Explain"));
crate::snap!("complete_explain_update", a); crate::snap!("complete_explain_update", a);
+16 -28
View File
@@ -143,10 +143,7 @@ fn after_values_keyword_expects_open_paren() {
// Trailing space so we're past the `values` word boundary // Trailing space so we're past the `values` word boundary
// — without it the partial-prefix logic re-offers `values` // — without it the partial-prefix logic re-offers `values`
// itself as the candidate that matches the typed prefix. // itself as the candidate that matches the typed prefix.
let a = assess_at_end( let a = assess_at_end("insert into Customers (Name) values ", &schema);
"insert into Customers (Name) values ",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
assert_candidate_present(&a, &["("]); assert_candidate_present(&a, &["("]);
crate::snap!("after_values_keyword", a); crate::snap!("after_values_keyword", a);
@@ -159,10 +156,7 @@ fn after_values_keyword_expects_open_paren() {
#[test] #[test]
fn after_values_open_paren_form_a_text_column_prose_names_column() { fn after_values_open_paren_form_a_text_column_prose_names_column() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers (Name) values (", &schema);
"insert into Customers (Name) values (",
&schema,
);
assert!( assert!(
hint_prose_contains(&a, "Name"), hint_prose_contains(&a, "Name"),
"expected column name in prose, got {:?}", "expected column name in prose, got {:?}",
@@ -179,13 +173,12 @@ fn after_values_open_paren_form_a_text_column_prose_names_column() {
#[test] #[test]
fn after_values_open_paren_form_a_serial_column_offers_null_to_auto_generate() { fn after_values_open_paren_form_a_serial_column_offers_null_to_auto_generate() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers (id, Name) values (", &schema);
"insert into Customers (id, Name) values (", let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
&schema, assert!(
prose.contains("id"),
"prose should name `id`, got {prose:?}"
); );
let prose = hint_prose(&a)
.unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
assert!(prose.contains("id"), "prose should name `id`, got {prose:?}");
assert!( assert!(
prose.contains("null") && prose.contains("auto-generate"), prose.contains("null") && prose.contains("auto-generate"),
"prose should mention `null` to auto-generate, got {prose:?}", "prose should mention `null` to auto-generate, got {prose:?}",
@@ -200,8 +193,7 @@ fn mid_value_list_after_comma_advances_to_next_column_prose() {
"insert into Customers (Name, Email) values ('Alice', ", "insert into Customers (Name, Email) values ('Alice', ",
&schema, &schema,
); );
let prose = hint_prose(&a) let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
.unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
assert!( assert!(
prose.contains("Email"), prose.contains("Email"),
"prose should name `Email`, got {prose:?}", "prose should name `Email`, got {prose:?}",
@@ -281,9 +273,8 @@ fn form_a_complete_with_serial_in_list_parses() {
fn form_a_int_slot_prose_says_integer() { fn form_a_int_slot_prose_says_integer() {
let schema = schema_every_type(); let schema = schema_every_type();
let a = assess_at_end("insert into Things (k) values (", &schema); let a = assess_at_end("insert into Things (k) values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose =
panic!("expected Prose for int slot, got {:?}", a.hint) hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for int slot, got {:?}", a.hint));
});
assert!( assert!(
prose.contains("integer"), prose.contains("integer"),
"int-slot prose should say `integer`, got {prose:?}", "int-slot prose should say `integer`, got {prose:?}",
@@ -295,9 +286,8 @@ fn form_a_int_slot_prose_says_integer() {
fn form_a_date_slot_prose_says_yyyy_mm_dd() { fn form_a_date_slot_prose_says_yyyy_mm_dd() {
let schema = schema_every_type(); let schema = schema_every_type();
let a = assess_at_end("insert into Things (dt) values (", &schema); let a = assess_at_end("insert into Things (dt) values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose =
panic!("expected Prose for date slot, got {:?}", a.hint) hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for date slot, got {:?}", a.hint));
});
assert!( assert!(
prose.contains("YYYY-MM-DD"), prose.contains("YYYY-MM-DD"),
"date-slot prose should reference YYYY-MM-DD format, got {prose:?}", "date-slot prose should reference YYYY-MM-DD format, got {prose:?}",
@@ -309,9 +299,8 @@ fn form_a_date_slot_prose_says_yyyy_mm_dd() {
fn form_a_bool_slot_prose_mentions_true_false() { fn form_a_bool_slot_prose_mentions_true_false() {
let schema = schema_every_type(); let schema = schema_every_type();
let a = assess_at_end("insert into Things (b) values (", &schema); let a = assess_at_end("insert into Things (b) values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose =
panic!("expected Prose for bool slot, got {:?}", a.hint) hint_prose(&a).unwrap_or_else(|| panic!("expected Prose for bool slot, got {:?}", a.hint));
});
assert!( assert!(
prose.contains("true") && prose.contains("false"), prose.contains("true") && prose.contains("false"),
"bool-slot prose should mention `true`/`false`, got {prose:?}", "bool-slot prose should mention `true`/`false`, got {prose:?}",
@@ -323,9 +312,8 @@ fn form_a_bool_slot_prose_mentions_true_false() {
fn form_a_shortid_slot_prose_mentions_null_to_auto_generate() { fn form_a_shortid_slot_prose_mentions_null_to_auto_generate() {
let schema = schema_every_type(); let schema = schema_every_type();
let a = assess_at_end("insert into Things (sid) values (", &schema); let a = assess_at_end("insert into Things (sid) values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose = hint_prose(&a)
panic!("expected Prose for shortid slot, got {:?}", a.hint) .unwrap_or_else(|| panic!("expected Prose for shortid slot, got {:?}", a.hint));
});
assert!( assert!(
prose.contains("null") && prose.contains("auto-generate"), prose.contains("null") && prose.contains("auto-generate"),
"shortid-slot prose should mention `null` to auto-generate, got {prose:?}", "shortid-slot prose should mention `null` to auto-generate, got {prose:?}",
+23 -53
View File
@@ -38,7 +38,10 @@ fn form_b_first_value_skips_serial_column() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end("insert into Customers values (", &schema); let a = assess_at_end("insert into Customers values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose = hint_prose(&a).unwrap_or_else(|| {
panic!("expected Prose at first Form B value slot, got {:?}", a.hint) panic!(
"expected Prose at first Form B value slot, got {:?}",
a.hint
)
}); });
// The value slot itself must be keyed on `Name` — the first // The value slot itself must be keyed on `Name` — the first
// non-auto column — not on the skipped `id`. // non-auto column — not on the skipped `id`.
@@ -60,9 +63,7 @@ fn form_b_first_value_skips_serial_column() {
fn form_b_first_value_text_pk_names_first_column() { fn form_b_first_value_text_pk_names_first_column() {
let schema = schema_text_pk(); let schema = schema_text_pk();
let a = assess_at_end("insert into Items values (", &schema); let a = assess_at_end("insert into Items values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
panic!("expected Prose, got {:?}", a.hint)
});
assert!( assert!(
prose.contains("Code"), prose.contains("Code"),
"Form B should name the PK column `Code`, got prose: {prose:?}", "Form B should name the PK column `Code`, got prose: {prose:?}",
@@ -76,9 +77,7 @@ fn form_b_first_value_every_type_first_column_is_int() {
// must say `integer` and name `k`. // must say `integer` and name `k`.
let schema = schema_every_type(); let schema = schema_every_type();
let a = assess_at_end("insert into Things values (", &schema); let a = assess_at_end("insert into Things values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
panic!("expected Prose, got {:?}", a.hint)
});
assert!( assert!(
prose.contains("k"), prose.contains("k"),
"should name column `k`, got prose: {prose:?}", "should name column `k`, got prose: {prose:?}",
@@ -98,13 +97,9 @@ fn form_b_first_value_every_type_first_column_is_int() {
#[test] #[test]
fn form_b_after_first_value_advances_to_next_column() { fn form_b_after_first_value_advances_to_next_column() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers values ('Alice', ", &schema);
"insert into Customers values ('Alice', ", let prose =
&schema, hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at second slot, got {:?}", a.hint));
);
let prose = hint_prose(&a).unwrap_or_else(|| {
panic!("expected Prose at second slot, got {:?}", a.hint)
});
assert!( assert!(
prose.contains("Email"), prose.contains("Email"),
"second slot should name `Email`, got prose: {prose:?}", "second slot should name `Email`, got prose: {prose:?}",
@@ -121,10 +116,7 @@ fn form_b_after_first_value_advances_to_next_column() {
#[test] #[test]
fn form_b_in_progress_after_comma_is_incomplete() { fn form_b_in_progress_after_comma_is_incomplete() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers values ('Alice', ", &schema);
"insert into Customers values ('Alice', ",
&schema,
);
assert!( assert!(
matches!(a.state, InputState::IncompleteAtEof), matches!(a.state, InputState::IncompleteAtEof),
"in-progress Form B should be Incomplete, got {:?}", "in-progress Form B should be Incomplete, got {:?}",
@@ -136,10 +128,7 @@ fn form_b_in_progress_after_comma_is_incomplete() {
#[test] #[test]
fn form_b_in_progress_without_closing_paren_is_incomplete() { fn form_b_in_progress_without_closing_paren_is_incomplete() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c'", &schema);
"insert into Customers values ('Alice', 'a@b.c'",
&schema,
);
assert!(matches!(a.state, InputState::IncompleteAtEof)); assert!(matches!(a.state, InputState::IncompleteAtEof));
crate::snap!("form_b_in_progress_no_close_paren", a); crate::snap!("form_b_in_progress_no_close_paren", a);
} }
@@ -201,10 +190,7 @@ fn form_b_with_extra_value_for_serial_column_is_invalid() {
#[test] #[test]
fn form_b_with_correct_values_parses() { fn form_b_with_correct_values_parses() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers values ('Alice', 'a@b.c')", &schema);
"insert into Customers values ('Alice', 'a@b.c')",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("Insert")); assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
crate::snap!("form_b_valid", a); crate::snap!("form_b_valid", a);
@@ -213,10 +199,7 @@ fn form_b_with_correct_values_parses() {
#[test] #[test]
fn form_b_text_pk_with_correct_values_parses() { fn form_b_text_pk_with_correct_values_parses() {
let schema = schema_text_pk(); let schema = schema_text_pk();
let a = assess_at_end( let a = assess_at_end("insert into Items values ('SKU-1', 'Widget')", &schema);
"insert into Items values ('SKU-1', 'Widget')",
&schema,
);
assert!(matches!(a.state, InputState::Valid)); assert!(matches!(a.state, InputState::Valid));
assert_eq!(a.parse_result.as_deref(), Ok("Insert")); assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
crate::snap!("form_b_text_pk_valid", a); crate::snap!("form_b_text_pk_valid", a);
@@ -240,9 +223,8 @@ fn form_b_text_pk_with_correct_values_parses() {
fn form_b_first_slot_mentions_skipped_serial_column() { fn form_b_first_slot_mentions_skipped_serial_column() {
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end("insert into Customers values (", &schema); let a = assess_at_end("insert into Customers values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose = hint_prose(&a)
panic!("expected Prose at first Form B slot, got {:?}", a.hint) .unwrap_or_else(|| panic!("expected Prose at first Form B slot, got {:?}", a.hint));
});
// Names the skipped auto-gen column. // Names the skipped auto-gen column.
assert!( assert!(
prose.contains("`id`"), prose.contains("`id`"),
@@ -261,13 +243,9 @@ fn form_b_second_slot_omits_skip_note() {
// The note fires once, at the first slot only — not at // The note fires once, at the first slot only — not at
// every comma. // every comma.
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers values ('Alice', ", &schema);
"insert into Customers values ('Alice', ", let prose =
&schema, hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at second slot, got {:?}", a.hint));
);
let prose = hint_prose(&a).unwrap_or_else(|| {
panic!("expected Prose at second slot, got {:?}", a.hint)
});
assert!( assert!(
!prose.contains("auto-generated"), !prose.contains("auto-generated"),
"second-slot hint must NOT repeat the skip note, got: {prose:?}", "second-slot hint must NOT repeat the skip note, got: {prose:?}",
@@ -280,9 +258,7 @@ fn form_b_text_pk_has_no_skip_note() {
// No auto-gen columns → no skip note. // No auto-gen columns → no skip note.
let schema = schema_text_pk(); let schema = schema_text_pk();
let a = assess_at_end("insert into Items values (", &schema); let a = assess_at_end("insert into Items values (", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
panic!("expected Prose, got {:?}", a.hint)
});
assert!( assert!(
!prose.contains("auto-generated"), !prose.contains("auto-generated"),
"text-PK table has no auto-gen column — no skip note expected, got: {prose:?}", "text-PK table has no auto-gen column — no skip note expected, got: {prose:?}",
@@ -295,13 +271,8 @@ fn form_a_first_slot_has_no_skip_note() {
// Form A lists columns explicitly — the user is in control, // Form A lists columns explicitly — the user is in control,
// no pedagogical pointer needed. // no pedagogical pointer needed.
let schema = schema_serial_pk(); let schema = schema_serial_pk();
let a = assess_at_end( let a = assess_at_end("insert into Customers (Name) values (", &schema);
"insert into Customers (Name) values (", let prose = hint_prose(&a).unwrap_or_else(|| panic!("expected Prose, got {:?}", a.hint));
&schema,
);
let prose = hint_prose(&a).unwrap_or_else(|| {
panic!("expected Prose, got {:?}", a.hint)
});
assert!( assert!(
!prose.contains("auto-generated"), !prose.contains("auto-generated"),
"Form A must not show the Form-B skip note, got: {prose:?}", "Form A must not show the Form-B skip note, got: {prose:?}",
@@ -315,9 +286,8 @@ fn form_b_advances_through_every_type_first_to_real() {
// first value, prose must name `r` and say `number`. // first value, prose must name `r` and say `number`.
let schema = schema_every_type(); let schema = schema_every_type();
let a = assess_at_end("insert into Things values (1, ", &schema); let a = assess_at_end("insert into Things values (1, ", &schema);
let prose = hint_prose(&a).unwrap_or_else(|| { let prose =
panic!("expected Prose at 2nd slot, got {:?}", a.hint) hint_prose(&a).unwrap_or_else(|| panic!("expected Prose at 2nd slot, got {:?}", a.hint));
});
assert!(prose.contains("r"), "should name `r`, got prose: {prose:?}"); assert!(prose.contains("r"), "should name `r`, got prose: {prose:?}");
assert!( assert!(
prose.contains("number"), prose.contains("number"),

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