12 Commits

Author SHA1 Message Date
claude@clouddev1 6d54c1e96c ci(publish): wire Scoop bucket + Homebrew tap jobs (D3 §3b/§3c)
ci / gate (push) Successful in 1m59s
ci / manifests (push) Successful in 4s
Add sibling publish.yaml jobs (scoop-bucket, homebrew-tap) that render a
manifest from the release .sha256 sidecars and idempotently push it to the
org-level lazyeval/scoop-bucket and lazyeval/homebrew-tap repos, using the
scoped lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN).

Render logic lives in dependency-free bash (the CI image has no jq/ruby):
scripts/render-scoop-manifest.sh and scripts/render-homebrew-formula.sh.
scripts/test-package-renders.sh exercises both: it validates the Scoop JSON
with node and asserts fields on both manifests, and additionally runs
`ruby -c` on the formula where ruby is present (dev box), skipping it
gracefully otherwise.

A new ci.yaml `manifests` job runs that test on every push so a render
regression surfaces immediately, not at the next manual publish dispatch.
The CI image has no ruby, so in CI the gate covers the Scoop JSON (node) and
field assertions for both manifests; the formula's Ruby syntax is checked
dev-side only (the static heredoc's variable parts cannot introduce syntax
errors).

- Scoop: x64 (gnu) + arm64 (gnullvm); #/-rename fragment so the bin shim is
  version-stable; checkver, no autoupdate (the pipeline is the updater).
- Homebrew: on_macos/on_linux x arch bare-binary formula; no Windows.

Docs: ADR-0056 Amendment 2 (+ README index, requirements D3).

Unverified pending real use: scoop/brew install, the HEAD:main branch
assumption, macOS Gatekeeper-via-brew on the ad-hoc-signed binary.
2026-06-19 21:30:18 +00:00
claude@clouddev1 c0531aa048 fix(install): use install.ps1 immediately + honest PATH guidance
The persisted User PATH only reaches newly-started processes, so the old
"restart your shell" advice was wrong — a sign-out/in was actually needed
(observed on Windows 11). Update the current session's $env:Path as well
so the command works right away in the same window, and reword the notice.

Also refresh the header: verified on ARM64 Windows 11 under Windows
PowerShell 5.1 and PowerShell 7.6 against the live v0.2.0 release.
2026-06-19 14:57:25 +00:00
claude@clouddev1 42b40bc099 fix(install): make install.ps1 work on Windows PowerShell 5.1
ci / gate (push) Successful in 3m30s
The in-box Windows shell (5.1, .NET Framework) is the baseline we must
support; PowerShell 7 is an opt-in install most users don't have.

- arch detection: read PROCESSOR_ARCHITECTURE/ARCHITEW6432 from the
  environment instead of RuntimeInformation::OSArchitecture, which
  resolves from a .NET Framework facade lacking that property under 5.1
  and throws under StrictMode (the reported failure).
- force TLS 1.2 before any web request (5.1 may default to TLS 1.0/1.1).
- pass -UseBasicParsing to Invoke-WebRequest (5.1 otherwise uses the IE
  engine and can fail when it is absent).

All three are no-ops on PowerShell 7. Relates to ADR-0055.
2026-06-19 14:22:28 +00:00
claude@clouddev1 cabc8131a9 docs: handoff 74 — road to public availability; v0.2.0 live on crates.io
ci / gate (push) Successful in 3m21s
2026-06-18 22:10:41 +00:00
claude@clouddev1 8ebe213b5d ci: add the publish.yaml workflow file (completes d3af1c4)
d3af1c4 described the manual publish workflow and updated ADR-0056, but
`git commit -am` doesn't stage new untracked files, so publish.yaml
itself was left out. Add it here.
2026-06-18 22:10:21 +00:00
claude@clouddev1 d3af1c413a ci: add a manual publish workflow (crates.io, idempotent + expandable)
A workflow_dispatch publish.yaml (mirrors release-macos.yaml) with a `tag`
input, run by hand once the automated release builds exist. Publishing
stays manual and keeps the registry token off every tag push: it's
irreversible (yank-only), the split release (tag Linux/Windows +
dispatched macOS) makes a human the 'all assets up' gate, and crates.io
has no Gitea-Actions trusted-publishing path. The crates.io job is
idempotent (crates.io API pre-check + cargo publish as backstop) and
independent (no inter-job needs), so future Scoop/Homebrew/winget jobs can
be added alongside without interfering or breaking re-runs. Token via the
CARGO_REGISTRY_TOKEN secret. ADR-0056 Amendment 1 + README index also
record 0.2.0 published + binstall verified.
2026-06-18 22:03:47 +00:00
claude@clouddev1 3c87dbb391 fix(ci): create the profile dir so macOS nix prune keeps the toolchain warm
The prune step's profile path $HOME/.cache/rdbms-ci/toolchain had no
parent dir, so `nix develop --profile` errored ("cannot read directory")
and — swallowed by `|| true` — never created the profile/gc-root. With
nothing rooting the toolchain, nix-collect-garbage deleted the whole
closure every run (~3.8 GiB re-downloaded each dispatch; confirmed in the
run-74 log). Add `mkdir -p` for the parent and drop the `|| true` on the
profile realization so a future breakage fails loudly. The VM stays
bounded as before, but the toolchain now persists across runs.

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

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

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

The actual `cargo publish` remains a gated maintainer step (token,
irreversible) at a new tagged release; real cargo-binstall validation
pending.
2026-06-17 21:25:45 +00:00
126 changed files with 9407 additions and 5008 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
+29 -3
View File
@@ -2,9 +2,15 @@
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
# just enter the flake devShell and run cargo.
#
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
# isn't clean under stock rustfmt; revisit on main). The release job (static
# binary for D2) and the platform matrix layer on later, step by step.
# Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
# issue #35). The release job (static binary for D2) and the platform matrix
# layer on later, step by step.
#
# A separate, lightweight `manifests` job logic-tests the package-manifest
# render scripts (Scoop/Homebrew) used by publish.yaml — bash + node only, no
# toolchain — so a render regression surfaces on the breaking push rather than
# weeks later at the next manual publish dispatch (ADR-0056 Amendment 2).
name: ci
on:
push:
@@ -39,7 +45,27 @@ jobs:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: fmt (check, stock defaults)
run: nix develop -c cargo fmt --check
- name: clippy (warnings denied)
run: nix develop -c cargo clippy --all-targets -- -D warnings
- name: test
run: nix develop -c cargo test --no-fail-fast
# Logic test for the package-manifest render scripts. Renders with DUMMY
# inputs and validates the output — it never publishes or touches the lazyeval
# repos (that is publish.yaml's manual job). Runs on the same image but skips
# nix: it needs only bash + node, both in the base image.
#
# NOTE: the CI image has no ruby, so the script's `ruby -c` formula syntax
# check is skipped here (it degrades gracefully); the Scoop JSON is still
# validated with node and both manifests' fields are asserted. Full formula
# syntax is checked dev-side (ruby present) on every pre-commit local run.
manifests:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: render-script tests (Scoop + Homebrew)
run: bash scripts/test-package-renders.sh
+203
View File
@@ -0,0 +1,203 @@
# Manual publication workflow (workflow_dispatch) — the outward, irreversible
# release steps a human triggers AFTER the automated `release.yaml` build has
# produced downloadable assets (and they've been eyeballed as good).
#
# Why manual + separate from release.yaml:
# * Publishing to a public registry is irreversible (crates.io versions can
# only be *yanked*, never deleted) — a human pulls this lever, and the
# registry token never sits on every tag push.
# * Our release is split (Linux/Windows on the tag, macOS dispatched), so a
# human is the natural "all assets are up — go" gate. crates.io publish
# reads SOURCE so it doesn't strictly need the release, but binstall's
# metadata points at the release assets — hence run this once builds exist.
#
# Structure: each registry is its OWN job with NO inter-job `needs`, so jobs run
# independently and one failing (or a newly-added one) never breaks another.
# Every job is IDEMPOTENT — re-dispatching when a target is already published is
# a clean no-op. Add Scoop / Homebrew / winget as sibling jobs here later.
name: publish
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish (e.g. v0.2.0)'
required: true
jobs:
crates-io:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: publish to crates.io (idempotent)
shell: bash
env:
TAG: ${{ inputs.tag }}
# A crate-scoped, publish-update crates.io token, stored as a Gitea
# Actions secret. `cargo publish` reads CARGO_REGISTRY_TOKEN from env.
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Source of truth = the [package] version at the checked-out tag
# (toolchain-free read; same approach as release.yaml's guard, which
# avoids the flake devShell's stdout banner corrupting a parse).
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
[ -n "$VER" ] || { echo "ERROR: could not read version from Cargo.toml" >&2; exit 1; }
if [ "$TAG" != "v$VER" ]; then
echo "ERROR: dispatch tag '$TAG' != 'v$VER' (Cargo.toml at that tag)" >&2
exit 1
fi
# Idempotency: if this version is already on crates.io, no-op.
# (crates.io requires a descriptive User-Agent per its data policy;
# without one the API returns 403.) Only an explicit 200 means
# "already there" — anything else proceeds, and `cargo publish` is the
# final backstop (it refuses to overwrite an existing version).
UA="rdbms-playground-release-ci (oliver@sturmnet.org)"
code=$(curl -sS -o /dev/null -w '%{http_code}' -A "$UA" \
"https://crates.io/api/v1/crates/rdbms-playground/$VER" || echo 000)
if [ "$code" = "200" ]; then
echo "rdbms-playground $VER is already on crates.io — nothing to do."
exit 0
fi
echo "crates.io returned HTTP $code for $VER (not 200) — proceeding to publish."
echo "publishing rdbms-playground $VER to crates.io ..."
nix develop -c cargo publish --locked
echo "published rdbms-playground $VER to crates.io."
# Update the lazyeval Scoop bucket (Windows). Renders the manifest from the
# release's .sha256 sidecars and commits it to lazyeval/scoop-bucket. Pushes
# with the lazyeval-ci bot token (LAZYEVAL_PKG_TOKEN), which is scoped — via
# the bot's org-team membership — to the lazyeval package repos only, so a
# leak cannot touch oli/rdbms-playground.
scoop-bucket:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4 # default ref (main) — current render script
- name: update the lazyeval Scoop bucket (idempotent)
shell: bash
env:
TAG: ${{ inputs.tag }}
# Passed via env, never inlined into the script, so the value stays
# masked in logs; it only materialises in the clone URL at runtime.
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
run: |
set -euo pipefail
VER="${TAG#v}"
echo "scoop: targeting rdbms-playground $VER ($TAG)"
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
fetch_hash() {
local asset="$1" line
echo "scoop: fetching $asset.sha256" >&2
line=$(curl -fsSL "$base/$asset.sha256") \
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
# First whitespace-delimited field is the hash. `read` is a bash
# builtin (no awk, which the slim CI image may lack).
local hash _
read -r hash _ <<<"$line"
printf '%s' "$hash"
}
h_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-pc-windows-gnu.exe")
h_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-pc-windows-gnullvm.exe")
echo "scoop: rendering manifest"
bash scripts/render-scoop-manifest.sh "$VER" "$h_x64" "$h_arm" > /tmp/rdbms-playground.json
node -e 'JSON.parse(require("fs").readFileSync("/tmp/rdbms-playground.json","utf8"))' \
|| { echo "ERROR: rendered Scoop manifest is not valid JSON" >&2; exit 1; }
work=$(mktemp -d)
echo "scoop: cloning lazyeval/scoop-bucket"
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/scoop-bucket.git" "$work"
cp /tmp/rdbms-playground.json "$work/rdbms-playground.json"
cd "$work"
git config user.name "lazyeval-ci"
git config user.email "ci@lazyeval.net"
git add rdbms-playground.json
if git diff --cached --quiet; then
echo "scoop: manifest already at $VER — nothing to commit."
exit 0
fi
git commit -m "rdbms-playground $VER"
# Push to main explicitly: a freshly-created (empty) repo clone may put
# the first commit on a differently-named local branch. Assumes the
# bucket/tap default branch is `main` (Gitea's default for new repos).
git push origin HEAD:main
echo "scoop: bucket updated to rdbms-playground $VER."
# Update the lazyeval Homebrew tap (macOS + Linux). Same shape as scoop-bucket;
# writes Formula/rdbms-playground.rb into lazyeval/homebrew-tap.
homebrew-tap:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: update the lazyeval Homebrew tap (idempotent)
shell: bash
env:
TAG: ${{ inputs.tag }}
PKG_TOKEN: ${{ secrets.LAZYEVAL_PKG_TOKEN }}
run: |
set -euo pipefail
VER="${TAG#v}"
echo "homebrew: targeting rdbms-playground $VER ($TAG)"
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/$TAG"
fetch_hash() {
local asset="$1" line
echo "homebrew: fetching $asset.sha256" >&2
line=$(curl -fsSL "$base/$asset.sha256") \
|| { echo "ERROR: cannot fetch $asset.sha256 — is $TAG released with assets?" >&2; exit 1; }
# First whitespace-delimited field is the hash. `read` is a bash
# builtin (no awk, which the slim CI image may lack).
local hash _
read -r hash _ <<<"$line"
printf '%s' "$hash"
}
mac_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-apple-darwin")
mac_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-apple-darwin")
lin_arm=$(fetch_hash "rdbms-playground-$TAG-aarch64-unknown-linux-musl")
lin_x64=$(fetch_hash "rdbms-playground-$TAG-x86_64-unknown-linux-musl")
echo "homebrew: rendering formula"
bash scripts/render-homebrew-formula.sh "$VER" "$mac_arm" "$mac_x64" "$lin_arm" "$lin_x64" \
> /tmp/rdbms-playground.rb
grep -q '^class RdbmsPlayground < Formula$' /tmp/rdbms-playground.rb \
|| { echo "ERROR: rendered formula looks malformed" >&2; exit 1; }
work=$(mktemp -d)
echo "homebrew: cloning lazyeval/homebrew-tap"
git clone --depth 1 "https://lazyeval-ci:${PKG_TOKEN}@git.lazyeval.net/lazyeval/homebrew-tap.git" "$work"
mkdir -p "$work/Formula"
cp /tmp/rdbms-playground.rb "$work/Formula/rdbms-playground.rb"
cd "$work"
git config user.name "lazyeval-ci"
git config user.email "ci@lazyeval.net"
git add Formula/rdbms-playground.rb
if git diff --cached --quiet; then
echo "homebrew: formula already at $VER — nothing to commit."
exit 0
fi
git commit -m "rdbms-playground $VER"
# Push to main explicitly: a freshly-created (empty) repo clone may put
# the first commit on a differently-named local branch. Assumes the
# bucket/tap default branch is `main` (Gitea's default for new repos).
git push origin HEAD:main
echo "homebrew: tap updated to rdbms-playground $VER."
# winget remains a future sibling job here (komac on Linux CI, or a manual PR
# to microsoft/winget-pkgs). No `needs:` between jobs — each is independent and
# idempotent, so one failing or being added never breaks another.
+11 -2
View File
@@ -84,12 +84,21 @@ jobs:
# The runner wipes the workspace each run, so cargo target/ never
# accumulates. Bound the persistent nix store by generation: record the
# current devShell as a generation of a persistent profile (in $HOME),
# keep the 2 newest, reclaim what older ones referenced.
# keep the 2 newest, reclaim what older ones referenced — so the
# toolchain stays *warm* across runs and only stale generations are GC'd.
#
# The profile's parent dir MUST exist first, or `nix develop --profile`
# errors ("cannot read directory …") and the profile/gc-root is never
# created — which made `nix-collect-garbage` delete the whole toolchain
# closure every run (re-downloaded ~3.8 GiB each time; the retention was
# silently broken by the swallowed `|| true`). No `|| true` on the
# profile realization now: a future breakage should fail loudly.
if: always()
run: |
echo "--- disk before ---"; df -h / | tail -1
P="$HOME/.cache/rdbms-ci/toolchain"
nix develop --profile "$P" -c true || true
mkdir -p "$(dirname "$P")"
nix develop --profile "$P" -c true
nix-env -p "$P" --delete-generations +2 || true
nix-collect-garbage || true
echo "--- disk after ---"; df -h / | tail -1
+11 -6
View File
@@ -33,12 +33,17 @@ jobs:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# CARGO_PKG_VERSION is the single source of truth; the binary reports
# it via --version / the `version` command. Parse it from cargo
# metadata (node is in the CI image; avoids assuming jq).
VER=$(nix develop -c cargo metadata --no-deps --format-version 1 \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).packages[0].version))')
echo "tag=$TAG cargo=$VER"
# Read the [package] version straight from Cargo.toml — toolchain-free
# and robust. (An earlier `nix develop -c cargo metadata | node` version
# broke: the flake devShell prints a banner to stdout, corrupting the
# JSON pipe.) `^version = ` is anchored, so it matches only the package
# version, never the `version = ` inside dependency inline tables.
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
echo "tag=$TAG cargo=v$VER"
if [ -z "$VER" ]; then
echo "ERROR: could not read the package version from Cargo.toml" >&2
exit 1
fi
if [ "$TAG" != "v$VER" ]; then
echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2
exit 1
+20
View File
@@ -0,0 +1,20 @@
# Contributing to rdbms-playground
Contributions are welcome — bug reports, ideas, and pull requests. The
project lives on Gitea at
<https://git.lazyeval.net/oli/rdbms-playground>; please file issues and
open pull requests there. It's approaching its first public release, so
the most useful contributions right now are bug reports and rough edges
you hit while learning.
## License of contributions
Unless you explicitly state otherwise, any contribution you intentionally
submit for inclusion in this project — as defined in the Apache-2.0
license — shall be **dual-licensed under `MIT OR Apache-2.0`** (the
project's licenses), without any additional terms or conditions.
This is the standard Rust "inbound = outbound" arrangement: your
contribution is offered under the same licenses the project distributes
under, so — via Apache-2.0 §5 — it carries the Apache-2.0 §3 patent grant
to all users. No separate CLA is required.
Generated
+1 -1
View File
@@ -1535,7 +1535,7 @@ dependencies = [
[[package]]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"arboard",
+41 -2
View File
@@ -1,12 +1,21 @@
[package]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
description = "A cross-platform TUI playground for learning relational databases."
license = "MIT OR Apache-2.0"
repository = "https://git.lazyeval.net/oli/rdbms-playground"
homepage = "https://relplay.org"
readme = "README.md"
publish = false
keywords = ["database", "sql", "tui", "learning", "playground"]
categories = ["command-line-utilities", "database"]
# Keep the published crate to the code that builds the binary — the website,
# decision records, and CI plumbing are repo-only (ADR-0056).
exclude = ["/website", "/docs", "/.gitea", "/.codegraph"]
# `publish = false` removed (ADR-0056): the crate is intended for
# crates.io. The actual `cargo publish` is a deliberate, irreversible
# maintainer step (needs the crates.io token) — do it at a tagged release
# whose assets the binstall metadata below points at.
[dependencies]
anyhow = "1.0.102"
@@ -85,3 +94,33 @@ nursery = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
# cargo-binstall (ADR-0056): let `cargo binstall rdbms-playground` fetch the
# prebuilt release binary instead of compiling from source. Our release assets
# are BARE binaries (no archive) named `rdbms-playground-v<version>-<target>`
# (`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003), so `pkg-fmt = "bin"`.
# `{ version }` excludes the leading `v`, so the template spells `v{ version }`.
#
# Target mapping: macOS host triples match our asset triples directly. But we
# ship the fully-static *-linux-MUSL build (glibc hosts are *-linux-gnu) and
# *-windows-GNU/GNULLVM (most Windows hosts are *-msvc), so those common host
# triples need explicit overrides pointing at the asset we actually publish.
#
# NOTE: unverified against a real `cargo binstall` run (binstall isn't a dep and
# nothing is on crates.io yet) — validate at the first publish + matching release.
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }{ archive-suffix }"
pkg-fmt = "bin"
bin-dir = "{ bin }{ binary-ext }"
[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-unknown-linux-musl"
[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-unknown-linux-musl"
[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-pc-windows-gnu.exe"
[package.metadata.binstall.overrides.aarch64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-pc-windows-gnullvm.exe"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Lazy Evaluation Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lazy Evaluation Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+98
View File
@@ -0,0 +1,98 @@
# rdbms-playground
A cross-platform terminal app for **learning relational database
concepts** — tables, columns, primary and foreign keys, relationships,
indexes, queries, and query plans — in a safe sandbox.
It's a teaching tool, not a database administration tool. It meets
beginners with guided, keyword-based commands (**simple mode**) and grows
with them to raw SQL (**advanced mode**), so the same playground works
from "what is a primary key?" through to writing real queries and reading
their execution plans.
Website & documentation: **<https://relplay.org>**
## Install
### One line (Linux / macOS)
```sh
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
```
Detects your OS and CPU, downloads the matching release binary, verifies
its SHA-256 checksum, and installs it to `~/.local/bin`. Set
`RDBMS_INSTALL_DIR` to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to
pin a version. (Prefer to read before you run? The script lives at
`scripts/install.sh`.)
### One line (Windows, PowerShell)
```powershell
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
```
Downloads the matching `.exe`, verifies its checksum, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds it to your user
PATH. Or use a package manager (Scoop / winget) once those land.
### With `cargo binstall`
If you have [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall)
(install it first — it is not part of `cargo` itself):
```sh
cargo binstall rdbms-playground
```
### From source
```sh
cargo install rdbms-playground # from crates.io
# or, from a clone:
cargo build --release # binary at target/release/rdbms-playground
```
### Prebuilt binaries
Every release publishes static Linux, standalone Windows, and macOS
binaries (x86_64 and aarch64) with `.sha256` checksums on the
[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases).
Windows users can also use the binary directly (package-manager support
is planned).
## A quick taste
```
create table Customers with pk id(serial)
add column Customers: name (text)
add column Customers: email (text)
insert into Customers values ('Ann', 'ann@example.io')
show data Customers
```
Press **F1** while typing for a contextual hint about the command you're
building, or type `help` for the full command list. Switch to raw SQL
with `mode advanced` (or prefix a single line with `:`).
## Project status
Approaching its first public release. See the website for current
features; installation via package managers (Homebrew, Scoop, winget) is
on the roadmap.
## License
Dual-licensed under either of
- MIT license ([LICENSE-MIT](LICENSE-MIT))
- Apache License 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
at your option.
## Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual-licensed as above, without any additional terms or
conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -37,7 +37,9 @@ version I'm running" — that drift is a correctness problem.
one rendered string and one version source.
3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a
**version guard**: it parses `CARGO_PKG_VERSION` from `cargo metadata`
**version guard**: it reads the `[package]` version directly from
`Cargo.toml` (`grep -m1 '^version = '` — toolchain-free; an earlier
`cargo metadata | node` form broke on the flake devShell's stdout banner)
and **fails the release** unless the pushed tag equals `v<that version>`.
So `--version`, the release name, and the downloaded asset are always in
lockstep — enforced by the machine, not by memory.
+13 -2
View File
@@ -52,11 +52,22 @@ Ship **`scripts/install.sh`**, run as
but adds a moving part; the **Gitea repo raw URL** is simplest and the
binaries live there anyway (user decision). The website may later
*reference* the same command.
- **`install.ps1` (Windows):** deferred — Windows users go via Scoop /
winget (D3, §3).
- **Uploading `install.sh` as a release asset** for a stable link:
optional; the branch raw URL is fine for now.
## Amendment 1 — `install.ps1` (Windows) added (2026-06-17)
Windows was originally deferred to Scoop/winget; the user opted for **both**
a PowerShell one-liner now *and* package managers later. Added
**`scripts/install.ps1`** (`irm <url> | iex`): maps the host CPU to our
`*-windows-gnu`/`-gnullvm` `.exe`, resolves the latest release (or
`-Version`/`RDBMS_VERSION`), downloads + **SHA-256-verifies**, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground` (`-InstallDir`/`RDBMS_INSTALL_DIR`
override), and adds that dir to the **user PATH**. **Caveat:** unlike
`install.sh` (verified end-to-end), this was **written but not tested from
this environment** (no PowerShell available) — validate on a real Windows
host. Scoop/winget (D3) remain the idiomatic package-manager routes.
## Consequences
- A first-time user runs one line and gets a checksum-verified binary on
@@ -0,0 +1,178 @@
# ADR-0056: crates.io publish-readiness + `cargo binstall` metadata (D3)
## Status
Accepted — **prepared 2026-06-17** (plan:
`docs/plans/20260616-public-availability.md`, step 3a). The crate is made
**ready to publish** and carries `cargo-binstall` metadata. The actual
`cargo publish` is a gated maintainer step (see Ordering). First D3
package-manager mechanism; builds on ADR-0054 (versioned releases),
ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR
(no Gitea issue — user decision).
## Context
`cargo binstall rdbms-playground` (and `cargo install`) need the crate on
**crates.io** (user decision, 2026-06-17). The manifest had
`publish = false`, a `readme = "README.md"` pointing at a **missing**
file, and no `keywords`/`categories`. Our release assets are **bare
binaries** (not archives) named `rdbms-playground-v<version>-<target>`
(`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003); critically the
**release target triples differ from users' host triples** — we ship the
static `*-linux-musl` build (hosts are `*-linux-gnu`) and
`*-windows-gnu`/`-gnullvm` (hosts are `*-msvc`); only macOS matches.
## Decision
**Publish-readiness (this change):**
- Drop `publish = false`; add `homepage = "https://relplay.org"`,
`keywords`, `categories = ["command-line-utilities", "database"]`, and
an `exclude` (`/website`, `/docs`, `/.gitea`, `/.codegraph`) so the
published crate is code-only (585 files/8.3 MiB → 353/913 KiB
compressed).
- Author **`README.md`** (the `readme` target + crates.io front page;
engine-neutral and "simple/advanced mode" wording per ADR-0002 / the
website copy rules), with install instructions (curl|sh, binstall,
source, prebuilt).
- Add **`LICENSE-MIT`** and **`LICENSE-APACHE`** (the latter the verbatim
canonical text, added by the maintainer; both © Lazy Evaluation Ltd —
the publication entity), and a **`CONTRIBUTING.md`** stating the
"inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes
the §3 patent grant explicit on the self-hosted forge). Dual license
kept (not MIT-only) — user decision after reviewing the patent-grant
rationale.
**`cargo binstall` metadata** (`[package.metadata.binstall]`, syntax
verified against cargo-binstall SUPPORT.md):
- `pkg-fmt = "bin"` (bare binary), `bin-dir = "{ bin }{ binary-ext }"`,
and a base `pkg-url` using `v{ version }` (the `{ version }` placeholder
excludes the leading `v`).
- **Per-target `overrides`** mapping the common host triples to the asset
we actually publish: `x86_64`/`aarch64-unknown-linux-gnu` → the `-musl`
asset; `x86_64`/`aarch64-pc-windows-msvc` → the `-gnu`/`-gnullvm`
`.exe`. macOS needs no override (host triple == asset triple). The docs
do **not** promise automatic musl/gnu or msvc/gnu fallback, hence
explicit overrides.
**Ordering / gating (important):**
- `cargo publish` is **irreversible** (needs the crates.io token; a
version can't be un-published, only yanked) — a deliberate **maintainer
step**, not done here.
- binstall's `pkg-url` resolves to a **tagged release's** assets, so
publish **at a new tagged version whose release already exists**, and
publish **after** that release is built. **Do not publish `0.1.0`** — it
would diverge from the already-released `0.1.0` binaries (which predate
`--version`, ADR-0054). The clean path: bump → tag → release builds →
`cargo publish`.
## Verification
- `cargo publish --dry-run --allow-dirty` packages + verify-builds cleanly
(353 files, 913 KiB compressed; no metadata errors).
- `cargo metadata` confirms the `binstall` block + all four `overrides`
parse.
- **Unverified:** an actual `cargo binstall` run — cargo-binstall isn't a
dependency and nothing is on crates.io yet. **Validate at the first
publish + matching release** (especially the windows-msvc→gnu and
linux-gnu→musl overrides).
## Consequences
- The crate can be published at the next tagged release with `cargo
publish` (+ the token); `cargo install rdbms-playground` and `cargo
binstall rdbms-playground` then work.
- Remaining D3: Scoop, Homebrew (`lazyeval` tap), winget (komac/manual) —
each a manifest + a per-release bump, tracked in the plan.
- Remaining follow-up: run the real `cargo binstall` validation at the
first publish + matching release (the license files, © holder, and
CONTRIBUTING are now in place).
## Amendment 1 — 2026-06-18: published live + a manual `publish` workflow
**`rdbms-playground 0.2.0` is published to crates.io** (`cargo install` and
`cargo binstall rdbms-playground` both verified working by the user). The
"unverified binstall" caveat is resolved — the per-target overrides
resolve correctly against the `v0.2.0` release assets.
**How publishing is wired:** a new **manual `workflow_dispatch` workflow**
(`.gitea/workflows/publish.yaml`), mirroring `release-macos.yaml`, takes a
`tag` input and runs `cargo publish` (token via the
`CARGO_REGISTRY_TOKEN` Gitea Actions secret — a crate-scoped,
publish-update token). **Not** automated on the tag, by decision: the
publish is irreversible (yank-only), keeping the registry token off every
tag push; the release is split (Linux/Windows on the tag, macOS
dispatched), so a human is the natural "all assets are up — go" gate; and
crates.io has no Gitea-Actions trusted-publishing path today, so a stored
token on the self-hosted runner would be the only automated option.
Each registry is its **own idempotent job** (no inter-job `needs`) — the
crates.io job skips cleanly if the version is already published (crates.io
API pre-check + `cargo publish` as the backstop) — so future
Scoop/Homebrew/winget jobs can be added alongside without breaking one
another or re-runs. The first such job's `tag`-vs-`Cargo.toml` guard
mirrors `release.yaml`.
## Amendment 2 — 2026-06-19: Scoop bucket + Homebrew tap (D3 §3b/§3c)
Two more package managers wired as **sibling `publish.yaml` jobs**
(`scoop-bucket`, `homebrew-tap`), following Amendment 1's independent +
idempotent pattern. Each fetches the release's `.sha256` sidecars, renders
a manifest, and commits it into a per-manager repo.
**Repos — org-level and multi-package.** Both live under a new **`lazyeval`
Gitea organisation** (created with the `oli` account, which gives the
`git.lazyeval.net/lazyeval/...` paths): `lazyeval/scoop-bucket` and
`lazyeval/homebrew-tap`. A Scoop *bucket* and a Homebrew *tap* are by
definition **collections of manifests**, so these are reusable for future
tools, not single-package repos. Homebrew's `homebrew-` repo-name prefix is
mandatory (→ referenced as `lazyeval/tap`); Scoop's bucket name is free.
Users: `scoop bucket add lazyeval <url>` (the label is local/arbitrary;
only the URL owner is real) then `scoop install rdbms-playground`; and
`brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
(the explicit-URL form — the `user/repo` shorthand assumes GitHub) then
`brew install lazyeval/tap/rdbms-playground`.
**Credential — a scoped bot user, not an `oli` PAT.** Gitea PATs scope by
**permission category, not per-repository** (`write:repository` grants
write to *every* repo the account can reach — there is no repo picker like
GitHub fine-grained PATs). So an `oli` token would also be able to push to
`oli/rdbms-playground` itself. Instead a dedicated bot user **`lazyeval-ci`**
is a member of a `lazyeval` org team with **Write** to the package repos
only; its `write:repository` PAT is therefore effectively scoped to those
repos and **cannot touch the main project repo**. Stored as the
`LAZYEVAL_PKG_TOKEN` Actions secret on `oli/rdbms-playground` (where the
workflow runs — *not* an org secret, which wouldn't reach a user-repo
workflow; *not* on the target repos, which only receive pushes). Passed via
`env:` (never inlined), so it stays masked and only materialises in the
clone URL at runtime; pushes go to `HEAD:main` (assumes the repos default
to `main`).
**Render scripts are dependency-free bash.** The CI job container is
`node:22-bookworm-slim` — **no jq, no ruby** — so
`scripts/render-{scoop-manifest,homebrew-formula}.sh` are pure bash
(heredocs, no external deps) taking a version + the relevant hashes and
emitting the manifest on stdout. `scripts/test-package-renders.sh` is their
test (JSON validated with `node` — present in the image — plus `jq`/`ruby`
when available; field-level assertions). The job validates the rendered
Scoop JSON with `node -e JSON.parse` before committing.
**Manifest specifics.**
- *Scoop* (`rdbms-playground.json` at bucket root): `64bit` =
`x86_64-pc-windows-gnu.exe`, `arm64` =
`aarch64-pc-windows-gnullvm.exe`; each URL carries a
`#/rdbms-playground.exe` rename fragment so the `bin` shim resolves
regardless of version. Carries `checkver` (lets `scoop status` / the
community excavator see lag) but **no `autoupdate`** — our pipeline is the
updater.
- *Homebrew* (`Formula/rdbms-playground.rb`): `on_macos`/`on_linux` ×
`on_arm`/`on_intel` selecting the four bare-binary assets (macOS direct;
Linux = the static `-musl` build). **Windows absent** — Homebrew has no
Windows port. `install` drops the single staged binary under a stable
name; the `test` block runs `--version`.
**Unverified (validate on first real use):** an actual `scoop install` and
`brew install`/`brew test`; the `HEAD:main` default-branch assumption; and
whether macOS Gatekeeper accepts the **ad-hoc-signed** mac binary via
`brew` (execution should be fine — ad-hoc satisfies arm64's signing
requirement and `brew`'s curl download sets no quarantine xattr, unlike a
browser download — but this rides on the still-parked Developer-ID signing
decision). **Remaining D3:** winget (komac on Linux CI, or a manual PR).
+3 -2
View File
@@ -66,5 +66,6 @@ This directory contains the project's ADRs, recorded per
- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus**`Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*)
- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases AD; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help <topic>` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.<hint_id>` (per command form) and `hint.err.<class>` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.<topic>`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint <topic>` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36)
- [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job parses `cargo metadata` and **fails the release** unless the `v*` tag equals `v<CARGO_PKG_VERSION>`). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`).
- [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL <gitea-raw>/scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: `install.ps1`, uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002).
- [ADR-0054 — Release versioning policy + version surfaces (`--version` / `version`)](0054-release-versioning-and-version-surfaces.md) — **Accepted + implemented 2026-06-16** (plan: `docs/plans/20260616-public-availability.md`, step 1 on the road to public availability; no prior issue/`requirements.md` item — an untracked gap). Fixes the **tag↔crate-version decoupling**: `Cargo.toml` built `0.1.0` while `release.yaml` named assets from the git tag, so a binary could report a version different from the asset it shipped in. **Decision:** `Cargo.toml` `version` is the **single source of truth** (read via `env!("CARGO_PKG_VERSION")`, no tag-injection); two surfaces report it through one `cli::version_text()` → catalog `cli.version_line` — a **`--version` / `-V`** CLI flag (mirrors `--help`, prints+exits in `main.rs`) and an in-app **`version`** command (REGISTRY node `app::VERSION`, `AppCommand::Version`, emits via `note_system`); and a **release-CI version guard** (`release.yaml` `test` job reads the `[package]` version from `Cargo.toml` and **fails the release** unless the `v*` tag equals `v<version>`; the guard's parse was later switched from `cargo metadata | node` to a `grep` on Cargo.toml after the former broke on the flake devShell's stdout banner). Release ritual: bump `Cargo.toml` → commit → tag → push. New keys `cli.version_line` + `help.app.version` + `parse.usage.version` + `hint.cmd.version.{what,example}` (the new REGISTRY command pulls in the comprehensiveness coverage gate). Rejected: tag-as-source (makes Cargo.toml lie). Deferred: git-hash/build-date enrichment (behind the same `version_text()` seam); UI placement beyond the command. Tested test-first: CLI parse (`--version`/`-V`/default-off), `version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses + emits. Also corrected a stale `release.yaml` header comment ("macOS is deferred" → built by the dispatched `release-macos.yaml`).
- [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL <gitea-raw>/scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows).
- [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc``*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget.
+10
View File
@@ -133,3 +133,13 @@ declaration of the dev *and* build environment.
flake for `requirements.md` **TT5** (CI runs the tiers) and the
**D1/D2/D3** distribution items (the release uses a static musl target
built through this flake).
## Amendment 1 — 2026-06-17: `fmt` gate enabled (issue #35)
The deferred "revisit on `main`" is done. With the CI + website branches
merged and before the first public release, the tree was reformatted once
with **stock `cargo fmt`** (no `rustfmt.toml` — stable rustfmt supports no
meaningful customisation, and the pinned 1.95.0 toolchain makes
`fmt --check` deterministic) in a single mechanical commit (`41b7e9a`,
102 files, behaviour-preserving; recorded in `.git-blame-ignore-revs`).
`ci.yaml`'s gate is now **`fmt --check` + clippy + test**. Closes **#35**.
+1 -1
View File
@@ -19,5 +19,5 @@ here too).
## Index
- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image**`node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision).
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard``x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible).
- [ADR-ci-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).
+137
View File
@@ -0,0 +1,137 @@
# Session handoff — 2026-06-18 (74)
Large session. Continues from handoff-73 (Ctrl-G demo alias). This one ran
the **road to public availability** end to end: a post-merge doc
reconciliation, then versioning, installers, crates.io/binstall, the
`cargo fmt` gate, and an actual **`v0.2.0` release that is now live on
crates.io**. Three new main-sequence ADRs (0054/0055/0056), one CI-ADR
amendment, issue **#35 closed**.
## §1. State
**Branch `main`.** **2509 pass / 0 fail / 1 ignored** (the long-standing
`friendly` doctest); **clippy clean**; **`cargo fmt --check` clean** (the
tree is now stock-rustfmt formatted, and CI gates it). `rdbms-playground
--version``rdbms-playground 0.2.0`.
**Released:** **`v0.2.0`** — Gitea release with all six D1 targets
(Linux/Windows via `release.yaml` on the tag; macOS via the dispatched
`release-macos.yaml`, **ad-hoc-signed**). **Published to crates.io**
(`cargo install rdbms-playground` and `cargo binstall rdbms-playground`
both user-verified).
**Push state:** earlier commits were pushed (they triggered CI/releases).
The **most recent commits are unpushed** and matter:
- `3c87dbb` macOS nix-prune fix (takes effect next macOS dispatch).
- `d3af1c4` + `8ebe213` the manual `publish.yaml` workflow — ADR-0056 in
the first, the **workflow file itself in the second** (`git commit -am`
had skipped the new untracked file).
- this handoff.
Push `main` to land them. (Push is the user's step.)
## §2. What shipped (commits `628b250`→`d3af1c4`)
- **`628b250` doc reconciliation** after the CI + website branch merges:
rewrote CLAUDE.md's stale repo-layout tree; added a Website subproject
note; fixed the CI note; **gitignored `.wrangler/` + `.vscode/`** and
removed a tracked `website/.vscode/`; updated requirements (D1 macOS
runtime-verified, DOC1 canonical-docs-on-website) + ADR-ci-003.
- **`c30a611` ADR-0054 — version surfaces.** `--version`/`-V` + an in-app
**`version`** command, both reading `CARGO_PKG_VERSION` via one
`cli::version_text()`. A **release-CI guard** fails the release unless
the `v*` tag equals `v<Cargo.toml version>`.
- **`ef99e6c` ADR-0055 — `scripts/install.sh`** (curl|sh, POSIX,
shellcheck-clean, checksum-verified, `~/.local/bin`). Verified
end-to-end against the live release. **`install.ps1`** (Windows
`irm|iex`) added later (`e9606b5`) — **written but untested here** (no
PowerShell on this box; validate on Windows).
- **`e9606b5` ADR-0056 — crates.io + binstall prep.** Publish-ready
Cargo.toml (dropped `publish=false`; homepage/keywords/categories/
`exclude`); `README.md`; `LICENSE-MIT`/`LICENSE-APACHE` (dual, © Lazy
Evaluation Ltd) + `CONTRIBUTING.md` (inbound=outbound); the
`[package.metadata.binstall]` block with per-target overrides
(linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct).
- **`41b7e9a` + `ec3c7c3`#35 fmt gate.** One mechanical `cargo fmt`
(stock defaults, 102 files, behaviour-preserving) recorded in
`.git-blame-ignore-revs`; `ci.yaml` now gates `fmt --check` (ADR-ci-002
Amendment 1). **Closes #35.**
- **`88830ed`+`bd5be5e` — v0.2.0 bump + the guard bug.** The first
`release.yaml` run **failed** at the version guard: it piped `nix
develop -c cargo metadata` to node, but the **flake devShell prints a
banner to stdout**, corrupting the JSON. Fixed to a toolchain-free
`grep -m1 '^version = ' Cargo.toml`. The `v0.2.0` tag was re-pointed
(Option A) to the fix commit; re-run went green.
- **`3c87dbb` — macOS nix-prune fix.** The prune step's profile dir
(`~/.cache/rdbms-ci`) didn't exist, so `nix develop --profile` errored
(swallowed by `|| true`) → the gc-root was never created → the whole
toolchain (~3.8 GiB) was deleted **and re-downloaded every run**. Added
`mkdir -p` + dropped the `|| true`. Diagnosed from the run-74 log via
`tea actions runs logs 74`.
- **`d3af1c4` — manual `publish.yaml`.** `workflow_dispatch` + `tag`
input (mirrors `release-macos.yaml`). Idempotent `crates-io` job
(crates.io API pre-check + `cargo publish` backstop), independent jobs
so Scoop/Homebrew/winget slot in later. ADR-0056 Amendment 1.
## §3. Live vs manual vs parked
- **Automated on a `v*` tag:** `release.yaml` builds + publishes the four
Linux/Windows targets (+ fmt/clippy/test gate).
- **Manual `workflow_dispatch`:** `release-macos.yaml` (mac binaries —
intermittent runner) and `publish.yaml` (crates.io now; more registries
later). Run them once the tag's build is up.
- **Parked (user decisions):**
- **macOS Developer-ID signing.** The pipeline **ad-hoc-signs**
(`codesign --sign -`). The user's `Apple Development` cert is the
**wrong type** — distribution needs **`Developer ID Application`** +
**notarization** (App Store Connect API key recommended). Fine for
`curl|sh` (no quarantine); matters for browser downloads. Details in
`docs/plans/20260616-public-availability.md`.
- **Remaining D3:** Scoop (`lazyeval` bucket), Homebrew (`lazyeval`
tap), winget (komac on Linux CI, or manual PR) — each a sibling job
in `publish.yaml` + a manifest repo.
## §4. Immediate next steps
1. **Push `main`** (lands `3c87dbb` + `d3af1c4`).
2. **Add the `CARGO_REGISTRY_TOKEN` secret** (crate-scoped,
`publish-update`) so `publish.yaml` works: `tea actions secrets create
CARGO_REGISTRY_TOKEN` (paste at prompt) or the Gitea UI.
3. **Smoke-test `publish.yaml`:** dispatch it for `v0.2.0` — it should
**idempotently skip** ("already on crates.io"), exercising the path
risk-free.
4. The release ritual going forward (ADR-0054): bump `Cargo.toml`
commit → tag `v<x.y.z>` → push tag (Linux/Windows release builds) →
dispatch `release-macos` → dispatch `publish`.
## §5. Gotchas learned (don't relearn the hard way)
- **The flake devShell prints a banner to stdout** — never pipe `nix
develop -c <cmd>` into a parser. Read Cargo.toml directly, etc.
- **Workflow-file source differs by trigger:** a **tag**-triggered run
(`release.yaml`) uses the workflow **at the tagged commit**; a
**`workflow_dispatch`** run (`release-macos`/`publish`) uses the
**default branch** (`main`). So fixing a dispatched workflow only needs
a `main` push; fixing a tag-triggered one needs the tag re-pointed.
- **Version vs tag:** `Cargo.toml` is bare `0.2.0`; the git tag is
`v0.2.0`; the guard checks `tag == "v" + version`; binstall `pkg-url`
spells `v{ version }`.
- **CI logs are reachable** via `tea actions runs logs <id>` (and `tea
actions runs list --output tsv`). Use it instead of guessing from a
step name.
- **crates.io API needs a descriptive User-Agent** (403 without one).
## §6. How to take over
1. Read handoffs 72 → 73 → 74, `CLAUDE.md`, `docs/requirements.md`, and
**`docs/plans/20260616-public-availability.md`** (the GA roadmap with
all decisions + parked items).
2. Confirm green: `cargo test` (**2509 / 1 ignored**), `cargo clippy
--all-targets`, `cargo fmt --check`.
3. ADRs for this arc: **0054** (versioning), **0055** (installer),
**0056** (crates.io/binstall + the publish workflow); CI side
**ADR-ci-002 Amendment 1** (fmt gate), **ADR-ci-003** (release matrix
+ macOS).
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
ADR amendment + README index-upkeep for decided-area changes, confirm
commit messages, never push.
5. Consider a `cargo sweep` at this milestone (`target/` grows).
+25 -8
View File
@@ -39,9 +39,13 @@ end-to-end against the live public `v0.1.0` release: platform mappings
(Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly),
pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256
verification (incl. a tamper-rejection check), install to
`~/.local/bin`, PATH hint. **`install.ps1` (Windows) deferred** — Windows
users go via Scoop/winget (§3). The website copy that references the
`curl` command is the **website branch's** job (separate agent), later.
`~/.local/bin`, PATH hint. **`install.ps1` (Windows) added 2026-06-17**
(user chose both a one-liner *and* Scoop/winget; ADR-0055 Amendment 1):
`irm | iex`, maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`,
SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH —
**written but untested from this env** (no PowerShell; validate on
Windows). The website copy that references these commands is the
**website branch's** job (separate agent), later.
A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not
added — shellcheck isn't in the flake yet; touches ADR-ci-002).
@@ -78,7 +82,18 @@ Original decided shape (for reference):
Common thread: a manifest pointing at our checksummed assets + a
per-release step to bump it. Ordered cheapest → most gatekept.
### 3a. `cargo binstall`
### 3a. `cargo binstall` + crates.io — PREPARED 2026-06-17 (ADR-0056)
**Done (prep):** crate made publish-ready (dropped `publish = false`;
added `homepage`/`keywords`/`categories`/`exclude`; authored `README.md`
+ `LICENSE-MIT`); `[package.metadata.binstall]` added with per-target
overrides (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct);
`cargo publish --dry-run` clean (913 KiB compressed). Dual license kept;
`LICENSE-MIT` + `LICENSE-APACHE` (© Lazy Evaluation Ltd) + `CONTRIBUTING.md`
(inbound=outbound) all in place. **Gated / remaining:** the actual `cargo
publish` (token, irreversible) at a **new tagged release** (not 0.1.0); a
real `cargo binstall` validation.
- **Bootstrapping matters (user-flagged):** `binstall` is **not** a
built-in cargo subcommand — users must install **`cargo-binstall`**
first (its own `curl|sh`/PowerShell installer, or
@@ -167,10 +182,12 @@ per-release step to bump it. Ordered cheapest → most gatekept.
command + CI tag-match guard + tests.
2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the
live public release.
3. **← next:** package managers, cheapest first: `cargo binstall`
(+ crates.io publish) + Scoop → Homebrew (`lazyeval` tap) → winget
(komac / manual). Two `lazyeval` repos (tap + bucket) + CI push creds
to set up.
3. Package managers, cheapest first:
- ✅ **`cargo binstall` + crates.io** — *prepared* (ADR-0056);
publish gated on a new tagged release + the token.
- **← next:** Scoop (`lazyeval` bucket) → Homebrew (`lazyeval` tap) →
winget (komac / manual). Two `lazyeval` repos (tap + bucket) + CI
push creds to set up.
4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 →
0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four
Linux/Windows targets build immediately. (macOS leg awaits signing.)
+10 -5
View File
@@ -85,11 +85,16 @@ since ADR-0027.)
No target requires anything the user must install. ADR-ci-003.)*
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
`winget`, and `cargo binstall`.
*(Prebuilt binaries + checksums now published to Gitea releases
(D1); the package-manager manifests (Homebrew / Scoop / winget /
`cargo binstall`) remain to do. The asset naming
`rdbms-playground-<tag>-<target>` is already binstall-friendly.
Tracked under ADR-ci-003 "Deferred".)*
*(Prebuilt binaries + checksums on Gitea releases (D1); **`cargo
binstall` + crates.io live** (ADR-0056); **Scoop + Homebrew wired**
(ADR-0056 Amendment 2) — `publish.yaml` `scoop-bucket` /
`homebrew-tap` jobs render dependency-free manifests from the release
`.sha256` sidecars and push them, via the scoped `lazyeval-ci` bot
token, to `lazyeval/scoop-bucket` and `lazyeval/homebrew-tap`;
rendering covered by `scripts/test-package-renders.sh`, end-to-end
install still to be user-verified. **Remaining: winget** (komac on
Linux CI, or a manual PR). Asset naming
`rdbms-playground-<tag>-<target>` is binstall-friendly.)*
## TUI shell
+122
View File
@@ -0,0 +1,122 @@
<#
.SYNOPSIS
Download and install a prebuilt rdbms-playground binary (Windows).
.DESCRIPTION
The Windows counterpart of scripts/install.sh. Detects the CPU
architecture, downloads the matching release .exe from the Gitea
releases, verifies its SHA-256 checksum, installs it to
%LOCALAPPDATA%\Programs\rdbms-playground, and adds that directory to
your user PATH.
Quick start:
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
We ship gnu / gnullvm Windows builds (x86_64 / aarch64); this maps the
host architecture to the right asset.
.PARAMETER Version
Install a specific tag (e.g. v0.2.0) instead of the latest release.
Defaults to $env:RDBMS_VERSION, else the latest release.
.PARAMETER InstallDir
Install directory. Defaults to $env:RDBMS_INSTALL_DIR, else
%LOCALAPPDATA%\Programs\rdbms-playground.
.NOTES
Verified end-to-end on ARM64 Windows 11 under both Windows PowerShell 5.1
and PowerShell 7.6, against the live v0.2.0 release. The x86_64 branch is
symmetric (env-based arch detection + a confirmed matching release asset)
but has not been run directly. The sibling installer is install.sh
(Linux/macOS).
#>
[CmdletBinding()]
param(
[string]$Version = $env:RDBMS_VERSION,
[string]$InstallDir = $(if ($env:RDBMS_INSTALL_DIR) { $env:RDBMS_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\rdbms-playground" })
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Windows PowerShell 5.1 (the in-box shell) can negotiate only TLS 1.0/1.1 by
# default, which modern hosts reject. Opt into TLS 1.2 without disturbing any
# protocols already enabled. (No-op on PowerShell 7.)
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Repo = 'https://git.lazyeval.net/oli/rdbms-playground'
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
$Bin = 'rdbms-playground'
# Map the host CPU to the target triple we publish for Windows.
# Read the architecture from the environment rather than
# RuntimeInformation::OSArchitecture: under Windows PowerShell 5.1 that type
# resolves from a .NET Framework facade that lacks OSArchitecture, which (with
# StrictMode) throws "property cannot be found". PROCESSOR_ARCHITECTURE is set
# on every PowerShell version; PROCESSOR_ARCHITEW6432 reports the true OS
# architecture when a 32-bit shell runs under WOW64.
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITEW6432')
if (-not $osArch) {
$osArch = [Environment]::GetEnvironmentVariable('PROCESSOR_ARCHITECTURE')
}
switch ($osArch) {
'AMD64' { $target = 'x86_64-pc-windows-gnu' }
'ARM64' { $target = 'aarch64-pc-windows-gnullvm' }
default { throw "install: unsupported CPU architecture: $osArch" }
}
# Resolve the release tag (explicit -Version, else the latest release).
if (-not $Version) {
$Version = (Invoke-RestMethod -Uri "$Api/releases/latest").tag_name
if (-not $Version) { throw 'install: could not determine the latest release tag' }
}
$asset = "$Bin-$Version-$target.exe"
$url = "$Repo/releases/download/$Version/$asset"
$tmp = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
$exe = Join-Path $tmp "$Bin.exe"
$shaFile = "$exe.sha256"
Write-Host "downloading $asset ..."
# -UseBasicParsing: Windows PowerShell 5.1's Invoke-WebRequest otherwise
# tries to use the Internet Explorer engine and can fail when it is absent.
# (No-op on PowerShell 7.)
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $exe
Invoke-WebRequest -UseBasicParsing -Uri "$url.sha256" -OutFile $shaFile
# The sidecar is "<hash> <name>"; compare just the hash.
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
$actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
if ($expected -ne $actual) {
throw "install: checksum mismatch (expected $expected, got $actual) — refusing to install"
}
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$dest = Join-Path $InstallDir "$Bin.exe"
Move-Item -Path $exe -Destination $dest -Force
Write-Host "installed $Bin $Version -> $dest"
# Persist the install dir on the user PATH (for future shells) if missing.
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' }
if (($userPath -split ';') -notcontains $InstallDir) {
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
}
# Also update THIS session's PATH so the command works immediately. The
# persisted change only reaches newly-started processes; an already-running
# shell (and, depending on how the terminal inherited its environment, even
# a freshly-opened one) won't see it until the next sign-out/in.
if (($env:Path -split ';') -notcontains $InstallDir) {
$env:Path = "$env:Path;$InstallDir"
}
Write-Host "added $InstallDir to your PATH — '$Bin' works in this window now."
Write-Host "for shells already open elsewhere, sign out and back in (or open a fresh one)."
}
finally {
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Render the Homebrew formula for rdbms-playground to stdout.
#
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
# version and the four macOS/Linux asset SHA-256 hashes it prints a complete
# formula. The publish.yaml `homebrew-tap` job fetches the hashes from the
# release .sha256 sidecars and commits the result into lazyeval/homebrew-tap as
# Formula/rdbms-playground.rb.
#
# The release assets are bare binaries (no archive), so Homebrew stages the
# single downloaded file in the build dir and `install` drops it under a stable
# name. Windows is intentionally absent — Homebrew has no Windows port (Scoop /
# winget cover Windows).
#
# Usage: render-homebrew-formula.sh <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>
# <version> version, with or without a leading 'v'
# <mac-arm> sha256 of aarch64-apple-darwin
# <mac-intel> sha256 of x86_64-apple-darwin
# <linux-arm> sha256 of aarch64-unknown-linux-musl
# <linux-intel> sha256 of x86_64-unknown-linux-musl
set -euo pipefail
if [ "$#" -ne 5 ]; then
echo "usage: $0 <version> <mac-arm> <mac-intel> <linux-arm> <linux-intel>" >&2
exit 2
fi
version=${1#v}
mac_arm=$2
mac_intel=$3
linux_arm=$4
linux_intel=$5
base="https://git.lazyeval.net/oli/rdbms-playground/releases/download/v$version"
# Ruby interpolations (#{version}, #{bin}) must survive verbatim into the
# formula; they contain no '$', so this unquoted heredoc leaves them untouched
# and only expands the shell variables below.
cat <<EOF
# typed: false
# frozen_string_literal: true
# rdbms-playground — installs the prebuilt release binary for the host
# platform. Regenerated for each release by scripts/render-homebrew-formula.sh;
# do not edit by hand.
class RdbmsPlayground < Formula
desc "Cross-platform TUI playground for learning relational databases"
homepage "https://relplay.org"
version "$version"
license any_of: ["MIT", "Apache-2.0"]
on_macos do
on_arm do
url "$base/rdbms-playground-v$version-aarch64-apple-darwin"
sha256 "$mac_arm"
end
on_intel do
url "$base/rdbms-playground-v$version-x86_64-apple-darwin"
sha256 "$mac_intel"
end
end
on_linux do
on_arm do
url "$base/rdbms-playground-v$version-aarch64-unknown-linux-musl"
sha256 "$linux_arm"
end
on_intel do
url "$base/rdbms-playground-v$version-x86_64-unknown-linux-musl"
sha256 "$linux_intel"
end
end
def install
# The release asset is a single bare binary; Homebrew stages it in the
# build dir under its (versioned) basename. Install it as a stable name.
bin.install Dir["*"].first => "rdbms-playground"
end
test do
assert_match "rdbms-playground #{version}", shell_output("#{bin}/rdbms-playground --version")
end
end
EOF
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Render the Scoop manifest for rdbms-playground to stdout.
#
# Pure function of its inputs — NO network, NO jq/ruby — so it runs unchanged in
# the CI job container (node:22-bookworm-slim: bash + coreutils only). Given a
# version and the two Windows asset SHA-256 hashes it prints a complete,
# schema-valid Scoop manifest. The publish.yaml `scoop-bucket` job fetches the
# hashes from the release's .sha256 sidecars and commits the result into the
# lazyeval/scoop-bucket repository as rdbms-playground.json.
#
# Manifest updates are CI-driven (this script, per release), so the manifest
# carries `checkver` (so `scoop status` / the community excavator can see when
# the bucket lags upstream) but deliberately NO `autoupdate` — our pipeline is
# the updater, not Scoop's maintainer tooling.
#
# Usage: render-scoop-manifest.sh <version> <hash-x64> <hash-arm64>
# <version> version, with or without a leading 'v' (e.g. 0.2.0 or v0.2.0)
# <hash-x64> sha256 of the x86_64-pc-windows-gnu.exe asset
# <hash-arm64> sha256 of the aarch64-pc-windows-gnullvm.exe asset
set -euo pipefail
if [ "$#" -ne 3 ]; then
echo "usage: $0 <version> <hash-x64> <hash-arm64>" >&2
exit 2
fi
# Accept either 0.2.0 or v0.2.0; the manifest 'version' field is bare.
version=${1#v}
hash_x64=$2
hash_arm64=$3
repo="https://git.lazyeval.net/oli/rdbms-playground"
base="$repo/releases/download/v$version"
# The `#/rdbms-playground.exe` fragment tells Scoop to save the versioned asset
# under a stable filename, so the `bin` shim resolves regardless of version.
url_x64="$base/rdbms-playground-v$version-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe"
url_arm64="$base/rdbms-playground-v$version-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe"
# Note: \$.tag_name emits a literal $ (Scoop's JSONPath); the regex uses [0-9.]
# rather than \d so the manifest contains no backslashes to escape.
cat <<EOF
{
"version": "$version",
"description": "A cross-platform TUI playground for learning relational databases.",
"homepage": "https://relplay.org",
"license": "MIT OR Apache-2.0",
"architecture": {
"64bit": {
"url": "$url_x64",
"hash": "$hash_x64"
},
"arm64": {
"url": "$url_arm64",
"hash": "$hash_arm64"
}
},
"bin": "rdbms-playground.exe",
"checkver": {
"url": "https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground/releases/latest",
"jsonpath": "\$.tag_name",
"regex": "v([0-9.]+)"
}
}
EOF
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
#
# Tests for the package-manifest render scripts (Scoop + Homebrew).
#
# Validates that each render script emits a well-formed manifest for given
# inputs, and that the inputs land in the right places. Dependency-light:
# requires node (always in the CI image) for JSON parsing; uses jq and ruby for
# extra checks when present (both available on the dev box). No network.
#
# Run: scripts/test-package-renders.sh
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
fail() { echo "FAIL: $*" >&2; exit 1; }
pass() { echo "ok: $*"; }
# Distinct dummy hashes so we can assert each lands in the right slot.
VER=9.9.9
H_WIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01
H_WIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa02
H_MAC_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa03
H_MAC_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa04
H_LIN_ARM=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa05
H_LIN_X64=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa06
# ---- Scoop ----------------------------------------------------------------
scoop=$tmp/rdbms-playground.json
# Pass a leading 'v' to confirm it gets stripped.
"$here/render-scoop-manifest.sh" "v$VER" "$H_WIN_X64" "$H_WIN_ARM" > "$scoop"
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$scoop" \
|| fail "scoop manifest is not valid JSON"
pass "scoop manifest parses as JSON"
# Field-level assertions via node (no jq dependency).
check_json() { # <jsexpr> <expected>
local got
got=$(node -e '
const m = JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
process.stdout.write(String(eval(process.argv[2])));
' "$scoop" "$1")
[ "$got" = "$2" ] || fail "scoop: $1 = '$got', expected '$2'"
}
check_json 'm.version' "$VER"
check_json 'm.bin' "rdbms-playground.exe"
check_json 'm.architecture["64bit"].hash' "$H_WIN_X64"
check_json 'm.architecture["arm64"].hash' "$H_WIN_ARM"
check_json 'm.checkver.jsonpath' '$.tag_name'
grep -q "rdbms-playground-v$VER-x86_64-pc-windows-gnu.exe#/rdbms-playground.exe" "$scoop" \
|| fail "scoop: x64 url/fragment missing"
grep -q "rdbms-playground-v$VER-aarch64-pc-windows-gnullvm.exe#/rdbms-playground.exe" "$scoop" \
|| fail "scoop: arm64 url/fragment missing"
pass "scoop manifest fields correct"
if command -v jq >/dev/null 2>&1; then
jq -e . "$scoop" >/dev/null || fail "scoop: jq rejected the manifest"
pass "scoop manifest valid per jq"
fi
# ---- Homebrew -------------------------------------------------------------
formula=$tmp/rdbms-playground.rb
"$here/render-homebrew-formula.sh" "$VER" "$H_MAC_ARM" "$H_MAC_X64" "$H_LIN_ARM" "$H_LIN_X64" > "$formula"
if command -v ruby >/dev/null 2>&1; then
ruby -c "$formula" >/dev/null || fail "homebrew formula is not valid Ruby"
pass "homebrew formula parses as Ruby"
else
echo "warn: ruby not present — skipping formula syntax check" >&2
fi
# Each hash must appear exactly once (right asset → right slot), the version
# must be present, and #{version} must survive verbatim for brew's test block.
for pair in \
"aarch64-apple-darwin:$H_MAC_ARM" \
"x86_64-apple-darwin:$H_MAC_X64" \
"aarch64-unknown-linux-musl:$H_LIN_ARM" \
"x86_64-unknown-linux-musl:$H_LIN_X64"; do
target=${pair%%:*}; hash=${pair#*:}
grep -q "rdbms-playground-v$VER-$target\"" "$formula" || fail "homebrew: url for $target missing"
grep -q "sha256 \"$hash\"" "$formula" || fail "homebrew: sha256 for $target missing"
done
grep -q 'version "'"$VER"'"' "$formula" || fail "homebrew: version line missing"
grep -q 'assert_match "rdbms-playground #{version}"' "$formula" \
|| fail "homebrew: test block #{version} interpolation was mangled"
grep -q 'rdbms-playground-v9.9.9-x86_64-pc-windows' "$formula" \
&& fail "homebrew: formula unexpectedly references a Windows asset"
pass "homebrew formula fields correct"
echo "all render tests passed"
+210 -188
View File
@@ -509,7 +509,10 @@ pub enum LoadPickerSubMode {
/// Switched to via `b`. Same input/cursor surface as
/// `PathEntryModal`; kept inline so the picker can flip
/// back to List with `Esc`.
PathEntry { input: String, cursor: usize },
PathEntry {
input: String,
cursor: usize,
},
}
const PAGE_SCROLL_LINES: usize = 5;
@@ -697,9 +700,7 @@ impl App {
// `trimmed[1..].trim()`.
let leading_ws = self.input.len() - self.input.trim_start().len();
let mut offset = leading_ws + 1; // past the `:`
while offset < self.input.len()
&& self.input.as_bytes()[offset].is_ascii_whitespace()
{
while offset < self.input.len() && self.input.as_bytes()[offset].is_ascii_whitespace() {
offset += 1;
}
let view = &self.input[offset..];
@@ -727,8 +728,7 @@ impl App {
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
let mode = match self.effective_mode() {
EffectiveMode::Simple => Mode::Simple,
EffectiveMode::AdvancedPersistent
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => Mode::Advanced,
};
// Strip the `:` one-shot prefix so the walker verdicts the SQL
// itself, not the escape marker (which it can't parse).
@@ -1037,10 +1037,7 @@ impl App {
Vec::new()
}
AppEvent::ExportSucceeded { path } => {
self.note_system(crate::t!(
"project.export_ok",
path = path.display()
));
self.note_system(crate::t!("project.export_ok", path = path.display()));
Vec::new()
}
AppEvent::ExportFailed { error } => {
@@ -1056,11 +1053,7 @@ impl App {
// `[ok] replay — N command(s)` summary is payload-bearing
// (the count) and stays.
self.mark_oldest_pending_echo(EchoStatus::Ok);
self.note_system(crate::t!(
"replay.completed",
path = path,
count = count
));
self.note_system(crate::t!("replay.completed", path = path, count = count));
// ADR-0034: surface `[skip]` warnings for app-lifecycle
// commands whose omission can leave the replayed state
// incomplete (`import`, nested `replay`).
@@ -1084,11 +1077,7 @@ impl App {
// it, mirroring how the interactive `running: …`
// path renders source-line context above an error.
if line_number == 0 {
self.note_error(crate::t!(
"replay.failed_open",
path = path,
error = error
));
self.note_error(crate::t!("replay.failed_open", path = path, error = error));
} else {
self.note_error(crate::t!(
"replay.failed_at_line",
@@ -1097,10 +1086,7 @@ impl App {
error = error
));
if !command.is_empty() {
self.note_error(crate::t!(
"replay.command_echo",
command = command
));
self.note_error(crate::t!("replay.command_echo", command = command));
}
}
Vec::new()
@@ -1237,8 +1223,7 @@ impl App {
// stays F1-only.
let hint_key = key.code == KeyCode::F(1)
|| (self.demo_mode
&& (key.code, key.modifiers)
== (KeyCode::Char('g'), KeyModifiers::CONTROL));
&& (key.code, key.modifiers) == (KeyCode::Char('g'), KeyModifiers::CONTROL));
if hint_key {
if self.input.trim().is_empty() {
self.note_hint_for_recent_error();
@@ -1369,8 +1354,8 @@ impl App {
/// against crossterm 0.29). Only active in demo mode (the caller
/// gates on `self.demo_mode`).
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let is_toggle = key.code == KeyCode::Char('5')
&& key.modifiers.contains(KeyModifiers::CONTROL);
let is_toggle =
key.code == KeyCode::Char('5') && key.modifiers.contains(KeyModifiers::CONTROL);
if self.demo_caption_capturing {
if is_toggle {
@@ -1379,8 +1364,7 @@ impl App {
self.demo_caption_capturing = false;
let text = std::mem::take(&mut self.demo_caption_buffer);
let trimmed = text.trim();
self.demo_caption =
(!trimmed.is_empty()).then(|| trimmed.to_string());
self.demo_caption = (!trimmed.is_empty()).then(|| trimmed.to_string());
} else {
match key.code {
// Plain characters accumulate invisibly; the prompt
@@ -1553,7 +1537,10 @@ impl App {
&self.schema_cache,
self.effective_mode().as_mode(),
)?;
comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset);
comp.replaced_range = (
comp.replaced_range.0 + offset,
comp.replaced_range.1 + offset,
);
Some(comp)
}
@@ -1581,8 +1568,7 @@ impl App {
idx: usize,
) -> crate::completion::LastCompletion {
let inserted = comp.candidates[idx].text.clone();
let original_text =
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
let original_text = self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
self.input
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
let new_end = comp.replaced_range.0 + inserted.len();
@@ -1777,7 +1763,10 @@ impl App {
// teaching echo (ADR-0038) on an advanced effective mode.
let (submission_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') {
(EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string())
(
EffectiveMode::AdvancedOneShot,
trimmed[1..].trim().to_string(),
)
} else if self.mode == Mode::Advanced {
(EffectiveMode::AdvancedPersistent, trimmed.to_string())
} else {
@@ -1845,11 +1834,7 @@ impl App {
/// simple and advanced modes; the parse-first refactor
/// (round-5) routes app commands here before the
/// mode-specific DSL/SQL paths.
fn dispatch_app_command(
&mut self,
cmd: crate::dsl::AppCommand,
source: &str,
) -> Vec<Action> {
fn dispatch_app_command(&mut self, cmd: crate::dsl::AppCommand, source: &str) -> Vec<Action> {
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
debug!(command = ?cmd, "dispatch app command");
match cmd {
@@ -2009,11 +1994,8 @@ impl App {
// mode so the walker gates SQL-only forms — simple-mode
// `select` returns the "this is SQL" hint as a normal
// parse error and is rendered through the Err arm below.
match crate::dsl::parser::parse_command_with_schema_in_mode(
input,
&self.schema_cache,
mode,
) {
match crate::dsl::parser::parse_command_with_schema_in_mode(input, &self.schema_cache, mode)
{
Ok(Command::Replay { path }) => {
// `replay` is parsed as a DSL command for the
// sake of grammar uniformity, but its execution
@@ -2127,15 +2109,9 @@ impl App {
.get(..*position)
.map_or(*position, |s| s.chars().count());
let pad = prefix.chars().count() + chars_before;
self.note_error(crate::t!(
"parse.caret",
padding = " ".repeat(pad)
));
self.note_error(crate::t!("parse.caret", padding = " ".repeat(pad)));
}
self.note_error(crate::t!(
"parse.error",
detail = parse_error_message(&err)
));
self.note_error(crate::t!("parse.error", detail = parse_error_message(&err)));
// ADR-0033 Amendment 3: combine the DSL error with a
// pointer to advanced mode when the same line would
// run as SQL there. Only in simple mode (a one-shot
@@ -2228,7 +2204,11 @@ impl App {
| Command::AddRelationship { .. }
| Command::DropRelationship { .. }
) {
debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)");
debug!(
verb = command.verb(),
width = self.last_output_width,
"render: relationship diagrams (ADR-0044)"
);
for line in crate::output_render::render_structure_with_diagrams(
desc,
self.last_output_width,
@@ -2252,11 +2232,7 @@ impl App {
}
}
fn handle_dsl_explain_success(
&mut self,
command: &Command,
plan: &crate::db::QueryPlan,
) {
fn handle_dsl_explain_success(&mut self, command: &Command, plan: &crate::db::QueryPlan) {
self.note_ok_summary(command);
// ADR-0028 §3: the display SQL, then the plan tree.
// `render_explain_plan` returns ready-built `OutputLine`s
@@ -2350,11 +2326,7 @@ impl App {
}
}
fn handle_dsl_add_column_success(
&mut self,
command: &Command,
result: AddColumnResult,
) {
fn handle_dsl_add_column_success(&mut self, command: &Command, result: AddColumnResult) {
self.note_ok_summary(command);
// ADR-0018 §9 / ADR-0038 §6 category 3: emit auto-fill note(s)
// before the structure render so the pedagogical "the tool did
@@ -2369,19 +2341,12 @@ impl App {
self.current_table = Some(result.description);
}
fn handle_dsl_drop_column_success(
&mut self,
command: &Command,
result: DropColumnResult,
) {
fn handle_dsl_drop_column_success(&mut self, command: &Command, result: DropColumnResult) {
self.note_ok_summary(command);
// ADR-0025: when `--cascade` removed covering indexes,
// name each one so the learner sees the side effect.
for index in &result.dropped_indexes {
self.note_system(crate::t!(
"ok.index_dropped_with_column",
index = index,
));
self.note_system(crate::t!("ok.index_dropped_with_column", index = index,));
}
for line in crate::output_render::render_structure(&result.description) {
self.note_system(line);
@@ -2415,10 +2380,7 @@ impl App {
lossy = note.lossy
)
} else {
crate::t!(
"client_side.transformed",
count = note.transformed
)
crate::t!("client_side.transformed", count = note.transformed)
};
self.push_category_three_prose(line);
}
@@ -2583,9 +2545,7 @@ impl App {
(Operation::RenameTable, Some(table.as_str()), None)
}
},
C::SqlCreateTable { name, .. } => {
(Operation::CreateTable, Some(name.as_str()), None)
}
C::SqlCreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None),
C::AddColumn { table, column, .. } => (
@@ -2635,9 +2595,7 @@ impl App {
// SQL `CREATE [UNIQUE] INDEX` shares the add-index operation
// (it reuses `do_add_index`); route engine/validation errors
// through it with the parsed table.
C::SqlCreateIndex { table, .. } => {
(Operation::AddIndex, Some(table.as_str()), None)
}
C::SqlCreateIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
C::AddConstraint { table, column, .. } => (
Operation::AddConstraint,
Some(table.as_str()),
@@ -2700,19 +2658,13 @@ impl App {
// `dispatch_input` routes them through
// `dispatch_app_command` before the DSL execution
// pipeline that this context builder feeds.
C::App(_) => unreachable!(
"App commands are dispatched before reaching dsl execution"
),
C::App(_) => unreachable!("App commands are dispatched before reaching dsl execution"),
};
TranslateContext {
operation: Some(operation),
table: facts
.table
.or_else(|| fallback_table.map(str::to_string)),
column: facts
.column
.or_else(|| fallback_column.map(str::to_string)),
table: facts.table.or_else(|| fallback_table.map(str::to_string)),
column: facts.column.or_else(|| fallback_column.map(str::to_string)),
child_table: facts.child_table,
parent_table: facts.parent_table,
parent_column: facts.parent_column,
@@ -2818,11 +2770,7 @@ impl App {
}
}
fn handle_path_entry_key(
&mut self,
key: KeyEvent,
mut state: PathEntryModal,
) -> Vec<Action> {
fn handle_path_entry_key(&mut self, key: KeyEvent, mut state: PathEntryModal) -> Vec<Action> {
match key.code {
KeyCode::Esc => {
self.modal = None;
@@ -2904,11 +2852,7 @@ impl App {
}
}
fn handle_load_picker_key(
&mut self,
key: KeyEvent,
mut state: LoadPickerModal,
) -> Vec<Action> {
fn handle_load_picker_key(&mut self, key: KeyEvent, mut state: LoadPickerModal) -> Vec<Action> {
match &mut state.sub_mode {
LoadPickerSubMode::List => match key.code {
KeyCode::Esc => {
@@ -3198,7 +3142,10 @@ impl App {
.map(|c| c.text.clone())
.collect::<Vec<_>>()
.join(", ");
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
self.push_category_three_prose(crate::t!(
"hint.ambient_expected",
expected = names
));
}
None => self.note_getting_started(),
}
@@ -3413,10 +3360,7 @@ fn render_usage_block(input: &str, mode: Mode) -> String {
.into_iter()
.map(|w| format!("`{w}`"))
.collect();
crate::t!(
"parse.available_commands",
commands = names.join(", ")
)
crate::t!("parse.available_commands", commands = names.join(", "))
}
fn render_cascade_effect(effect: &CascadeEffect) -> String {
@@ -3424,9 +3368,7 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String {
let action_key = match effect.action {
ReferentialAction::Cascade => "db.cascade.action_deleted",
ReferentialAction::SetNull => "db.cascade.action_set_null",
ReferentialAction::Restrict | ReferentialAction::NoAction => {
"db.cascade.action_blocked"
}
ReferentialAction::Restrict | ReferentialAction::NoAction => "db.cascade.action_blocked",
};
crate::t!(
"db.cascade.summary",
@@ -3464,7 +3406,10 @@ mod tests {
fn demo_badge_label_maps_the_invisible_keys() {
let none = KeyModifiers::NONE;
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)),
Some("[SHIFT-TAB]")
);
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
@@ -3474,8 +3419,14 @@ mod tests {
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::PageDown, none)),
Some("[PGDN]")
);
assert_eq!(
demo_badge_label(&ke(KeyCode::Backspace, none)),
Some("[BKSP]")
);
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
@@ -3493,12 +3444,24 @@ mod tests {
#[test]
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
// Plain characters render their own glyph — no badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)),
None
);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)),
None
);
// Quit and the (Phase C) caption toggle are deliberately excluded.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)),
None
);
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)),
None
);
}
#[test]
@@ -3606,7 +3569,10 @@ mod tests {
assert!(app.demo_caption_capturing, "still capturing");
assert_eq!(app.demo_caption_buffer, "note");
assert_eq!(app.input, "");
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
assert_eq!(
app.demo_badge, None,
"inert keys raise no badge while capturing"
);
}
#[test]
@@ -4211,7 +4177,9 @@ mod tests {
type_str(&mut app, "copy sideways");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))),
!actions
.iter()
.any(|a| matches!(a, Action::CopyToClipboard(_))),
"an unknown target does not copy",
);
let rendered = app
@@ -4399,7 +4367,10 @@ mod tests {
);
// … names the table's columns so the user can see what's needed …
assert!(
out.contains("Name") && out.contains("Age") && out.contains("id") && out.contains("SerNo"),
out.contains("Name")
&& out.contains("Age")
&& out.contains("id")
&& out.contains("SerNo"),
"missing the column-name list in: {out}",
);
// … and shows the column-list override targeting the non-auto columns.
@@ -4423,9 +4394,15 @@ mod tests {
let _ = submit(&mut app);
let out = error_lines(&app);
// The teaching line names the user-supplied columns …
assert!(out.contains("Name") && out.contains("Age"), "missing non-auto column names in: {out}");
assert!(
out.contains("Name") && out.contains("Age"),
"missing non-auto column names in: {out}"
);
// … the auto-generated columns …
assert!(out.contains("id") && out.contains("SerNo"), "missing auto column names in: {out}");
assert!(
out.contains("id") && out.contains("SerNo"),
"missing auto column names in: {out}"
);
// … signals the contract …
assert!(
out.contains("auto-generated"),
@@ -4520,10 +4497,7 @@ mod tests {
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
app.mode = Mode::Advanced;
type_str(
&mut app,
"insert into Customers values (13, 'Oli', 42, 13)",
);
type_str(&mut app, "insert into Customers values (13, 'Oli', 42, 13)");
let actions = submit(&mut app);
assert!(
actions
@@ -4552,7 +4526,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
);
}
@@ -4565,7 +4541,9 @@ mod tests {
type_str(&mut app, "insert into Customers values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
);
}
@@ -4580,7 +4558,9 @@ mod tests {
type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
);
}
@@ -4616,9 +4596,7 @@ mod tests {
for c in &tc {
app.schema_cache.columns.push(c.name.clone());
}
app.schema_cache
.table_columns
.insert("T".to_string(), tc);
app.schema_cache.table_columns.insert("T".to_string(), tc);
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
let actions = submit(&mut app);
assert!(
@@ -4649,11 +4627,11 @@ mod tests {
// advanced-mode hint at all, so we look for any line carrying
// the "mode advanced" actionable fragment that the pointer
// always emits.
let has_pointer = app
.output
.iter()
.any(|l| l.text.contains("mode advanced"));
assert!(!has_pointer, "unknown command must not point at advanced mode");
let has_pointer = app.output.iter().any(|l| l.text.contains("mode advanced"));
assert!(
!has_pointer,
"unknown command must not point at advanced mode"
);
}
#[test]
@@ -4702,7 +4680,11 @@ mod tests {
app.mode = mode;
type_str(&mut app, input);
match submit(&mut app).as_slice() {
[Action::ExecuteDsl { submission_mode, .. }] => *submission_mode,
[
Action::ExecuteDsl {
submission_mode, ..
},
] => *submission_mode,
other => panic!("expected one ExecuteDsl; got {other:?}"),
}
};
@@ -4733,7 +4715,9 @@ mod tests {
app.update(AppEvent::DslSucceeded {
command: cmd.clone(),
description: None,
echo: Some(vec!["CREATE TABLE Other (id serial PRIMARY KEY)".to_string()]),
echo: Some(vec![
"CREATE TABLE Other (id serial PRIMARY KEY)".to_string(),
]),
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
// ADR-0040: no `[ok]` summary; with no preceding `running:` echo
@@ -4792,7 +4776,10 @@ mod tests {
.position(|t| t.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}");
assert!(texts[echo_idx].contains(expected), "echo carries the SQL: {texts:?}");
assert!(
texts[echo_idx].contains(expected),
"echo carries the SQL: {texts:?}"
);
// ADR-0038 §4 polish: every success arm now wires the echo as
// `OutputKind::TeachingEcho` so `ui::render_output_line` fires
// the dim-prefix + advanced-lex custom branch. Pinning this
@@ -4911,7 +4898,9 @@ mod tests {
description: sample_description("T"),
client_side: None,
},
echo: Some(vec!["ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()]),
echo: Some(vec![
"ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string(),
]),
dont_convert_caveat: false,
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
@@ -5126,9 +5115,7 @@ mod tests {
dont_convert_caveat: false,
});
assert!(
!app.output
.iter()
.any(|l| l.text.contains("--dont-convert")),
!app.output.iter().any(|l| l.text.contains("--dont-convert")),
"no caveat in simple mode (no echo to refer to)",
);
}
@@ -5198,7 +5185,10 @@ mod tests {
);
// Pin the `Executing SQL:` prefix repeats once per statement
// (the plain-rendering shape until the styled-runs polish lands).
let exec_count = texts.iter().filter(|t| t.contains("Executing SQL:")).count();
let exec_count = texts
.iter()
.filter(|t| t.contains("Executing SQL:"))
.count();
assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
}
@@ -5223,19 +5213,13 @@ mod tests {
// (could be the caret line, the parse-error detail
// line, or the usage line). Scan for the friendly
// "unknown mode" anchor phrase.
let anywhere = app
.output
.iter()
.any(|l| l.text.contains("unknown mode"));
let anywhere = app.output.iter().any(|l| l.text.contains("unknown mode"));
assert!(
anywhere,
"expected 'unknown mode' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
);
let any_error = app
.output
.iter()
.any(|l| l.kind == OutputKind::Error);
let any_error = app.output.iter().any(|l| l.kind == OutputKind::Error);
assert!(any_error, "expected at least one Error line");
}
@@ -5325,11 +5309,17 @@ mod tests {
app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
app.schema_cache.table_columns.insert(
"Orders".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("customer_id", Type::Int)],
vec![
TableColumn::new("id", Type::Serial),
TableColumn::new("customer_id", Type::Int),
],
);
app.schema_cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("name", Type::Text)],
vec![
TableColumn::new("id", Type::Serial),
TableColumn::new("name", Type::Text),
],
);
for t in app.schema_cache.tables.clone() {
for c in &app.schema_cache.table_columns[&t] {
@@ -5490,10 +5480,7 @@ mod tests {
detail: "SCAN Customers".to_string(),
}],
};
app.update(AppEvent::DslExplainSucceeded {
command: cmd,
plan,
});
app.update(AppEvent::DslExplainSucceeded { command: cmd, plan });
// ADR-0040: no `[ok] explain …` header — the (no-echo here)
// command's success shows via the marker; the plan output
// itself carries the content.
@@ -5549,7 +5536,11 @@ mod tests {
.iter()
.find(|l| l.kind == OutputKind::Echo)
.expect("dispatch pushed an echo");
assert_eq!(echo.status, Some(EchoStatus::Pending), "pending before result");
assert_eq!(
echo.status,
Some(EchoStatus::Pending),
"pending before result"
);
app.update(AppEvent::DslSucceeded {
command: Command::CreateTable {
name: "T".to_string(),
@@ -5639,8 +5630,14 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
assert!(
text.contains("import a.zip"),
"import skip warning rendered:\n{text}"
);
assert!(
text.contains("nested `replay x`"),
"nested-replay skip warning rendered:\n{text}"
);
}
#[test]
@@ -5736,7 +5733,7 @@ mod tests {
#[test]
fn hint_command_parses_to_app_hint() {
use crate::dsl::{parse_command, AppCommand, Command};
use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!(
parse_command("hint"),
Ok(Command::App(AppCommand::Hint))
@@ -5747,7 +5744,7 @@ mod tests {
#[test]
fn version_command_parses_to_app_version() {
use crate::dsl::{parse_command, AppCommand, Command};
use crate::dsl::{AppCommand, Command, parse_command};
assert!(matches!(
parse_command("version"),
Ok(Command::App(AppCommand::Version))
@@ -5801,7 +5798,10 @@ mod tests {
let mut app = App::new();
type_str(&mut app, "show ");
app.update(key(KeyCode::Tab));
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
assert!(
app.last_completion.is_some(),
"precondition: Tab sets the memo"
);
let input = app.input.clone();
f1(&mut app);
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
@@ -5827,8 +5827,14 @@ mod tests {
let input = app.input.clone();
let before = app.output.len();
app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not change the buffer (no `g` typed)");
assert!(app.output.len() > before, "Ctrl-G must emit the same hint F1 does");
assert_eq!(
app.input, input,
"Ctrl-G must not change the buffer (no `g` typed)"
);
assert!(
app.output.len() > before,
"Ctrl-G must emit the same hint F1 does"
);
}
#[test]
@@ -5855,7 +5861,11 @@ mod tests {
let before = app.output.len();
app.update(ctrl_g());
assert_eq!(app.input, input, "Ctrl-G must not insert a `g`");
assert_eq!(app.output.len(), before, "Ctrl-G does nothing when demo mode is off");
assert_eq!(
app.output.len(),
before,
"Ctrl-G does nothing when demo mode is off"
);
}
#[test]
@@ -5949,7 +5959,10 @@ mod tests {
#[test]
fn f1_on_add_relationship_renders_the_relationship_block() {
let mut app = App::new();
type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
type_str(
&mut app,
"add 1:n relationship from Customers.id to Orders.cust ",
);
f1(&mut app);
assert!(
output_contains(&app, "one parent, many children"),
@@ -6142,14 +6155,8 @@ mod tests {
let mut app = App::new();
let cmd = Command::Update {
table: "Customers".to_string(),
assignments: vec![(
"id".to_string(),
crate::dsl::Value::Number("7".to_string()),
)],
filter: crate::dsl::RowFilter::eq(
"name",
crate::dsl::Value::Text("Bob".to_string()),
),
assignments: vec![("id".to_string(), crate::dsl::Value::Number("7".to_string()))],
filter: crate::dsl::RowFilter::eq("name", crate::dsl::Value::Text("Bob".to_string())),
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
@@ -6709,7 +6716,10 @@ mod tests {
app.update(key(KeyCode::Backspace));
let actions = app.update(key(KeyCode::Enter));
assert_eq!(app.input, "select", "input untouched in navigation mode");
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
assert!(
actions.is_empty(),
"Enter does not submit in navigation mode"
);
}
#[test]
@@ -6720,7 +6730,10 @@ mod tests {
app.update(key(KeyCode::Down));
app.update(key(KeyCode::Down));
assert_eq!(app.tables_scroll, 2);
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
assert_eq!(
app.relationships_scroll, 0,
"only the focused panel scrolls"
);
app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 1);
// Up saturates at the top.
@@ -6998,7 +7011,8 @@ mod tests {
for round in 0..3 {
app.update(key(KeyCode::Up));
assert_eq!(
app.input, "insert into Thing values (1)",
app.input,
"insert into Thing values (1)",
"Up #{} should recall the newest entry",
round + 1,
);
@@ -7220,8 +7234,7 @@ mod tests {
has_default: false,
}],
);
app.input =
"select * from products where price like 5".to_string();
app.input = "select * from products where price like 5".to_string();
assert_eq!(
app.input_validity_verdict(),
Some(crate::dsl::walker::Severity::Warning),
@@ -7350,8 +7363,10 @@ mod tests {
"directly-deleted count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")
&& t.contains("relationship `places`")),
texts
.iter()
.any(|t| t.contains("2 row(s) deleted in `Orders`")
&& t.contains("relationship `places`")),
"per-relationship cascade summary surfaced: {texts:?}",
);
}
@@ -7386,11 +7401,15 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("20 row(s) seeded into users")),
texts
.iter()
.any(|t| t.contains("20 row(s) seeded into users")),
"seeded-row count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("status") && t.contains("generic text")),
texts
.iter()
.any(|t| t.contains("status") && t.contains("generic text")),
"the advisory names the enum-ish column: {texts:?}",
);
}
@@ -7424,8 +7443,9 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("4 row(s) seeded into J")
&& t.contains("of 10 requested")),
texts
.iter()
.any(|t| t.contains("4 row(s) seeded into J") && t.contains("of 10 requested")),
"the cap note surfaces requested vs produced: {texts:?}",
);
}
@@ -7464,7 +7484,9 @@ mod tests {
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
texts
.iter()
.any(|t| t.contains("2 row(s) deleted in `Orders`")),
"cascade summary still surfaces alongside RETURNING: {texts:?}",
);
assert!(
+37 -37
View File
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
limit = format_args!("{limit:02}"),
))
}
Self::InvalidZip(detail) => f.write_str(&crate::t!(
"archive.invalid_zip",
detail = detail,
)),
Self::NotAProjectArchive => {
f.write_str(&crate::t!("archive.not_a_project_archive"))
Self::InvalidZip(detail) => {
f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
}
Self::MultipleTopFolders => {
f.write_str(&crate::t!("archive.multiple_top_folders"))
Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")),
Self::UnsafeEntry(entry) => {
f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,))
}
Self::UnsafeEntry(entry) => f.write_str(&crate::t!(
"archive.unsafe_entry",
entry = entry,
)),
}
}
}
@@ -216,13 +208,7 @@ pub fn export_project(
.unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive(
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(),
@@ -392,10 +378,7 @@ pub struct ZipInspection {
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name);
if !direct.exists() {
return Ok((direct, 0));
@@ -495,10 +478,12 @@ pub fn extract_into(
source,
})?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
entry
.read_to_end(&mut buf)
.map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
@@ -523,7 +508,11 @@ mod tests {
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
fs::write(
p.join(HISTORY_LOG),
"T|ok|create table Customers with pk id(serial)\n",
)
.unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
@@ -536,7 +525,9 @@ mod tests {
)
.unwrap();
fs::write(
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
p.join(crate::undo::SNAPSHOTS_DIR)
.join("0")
.join(PLAYGROUND_DB),
[0u8; 16],
)
.unwrap();
@@ -618,11 +609,15 @@ mod tests {
let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
w.start_file("foo/bar.txt", SimpleFileOptions::default())
.unwrap();
w.write_all(b"hi").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
assert!(
matches!(err, ArchiveError::NotAProjectArchive),
"got: {err:?}"
);
}
#[test]
@@ -631,13 +626,18 @@ mod tests {
let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("a/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
assert!(
matches!(err, ArchiveError::MultipleTopFolders),
"got: {err:?}"
);
}
#[test]
+44 -24
View File
@@ -96,10 +96,7 @@ pub enum ArgsError {
impl std::fmt::Display for ArgsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingValue(flag) => f.write_str(&crate::t!(
"cli.missing_value",
flag = flag,
)),
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
Self::InvalidValue {
flag,
value,
@@ -110,10 +107,7 @@ impl std::fmt::Display for ArgsError {
value = value,
expected = expected,
)),
Self::Unknown(arg) => f.write_str(&crate::t!(
"cli.unknown_argument",
arg = arg,
)),
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
"cli.multiple_paths",
first = first,
@@ -261,7 +255,11 @@ fn default_theme() -> Theme {
// Standard convention: 0..=6 and 8 are dark backgrounds,
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
let is_dark = matches!(code, 0..=6 | 8);
return if is_dark { Theme::dark() } else { Theme::light() };
return if is_dark {
Theme::dark()
} else {
Theme::light()
};
}
Theme::default()
}
@@ -314,10 +312,19 @@ mod tests {
#[test]
fn mode_flag_simple_and_advanced() {
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "simple"]).unwrap().mode,
Some(Mode::Simple)
);
assert_eq!(
Args::parse(["--mode", "advanced"]).unwrap().mode,
Some(Mode::Advanced)
);
// Case-insensitive, like the `mode` command.
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
Some(Mode::Advanced)
);
}
#[test]
@@ -350,7 +357,10 @@ mod tests {
#[test]
fn data_dir_flag_parses() {
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/playground-data"))
);
}
#[test]
@@ -370,13 +380,11 @@ mod tests {
#[test]
fn data_dir_and_positional_can_coexist() {
let args = Args::parse([
"--data-dir",
"/tmp/data",
"/home/me/MyProject",
])
.unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/data"))
);
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
@@ -386,7 +394,10 @@ mod tests {
#[test]
fn two_positional_paths_error() {
let err = Args::parse(["/a", "/b"]).unwrap_err();
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
assert!(
matches!(err, ArgsError::MultiplePaths { .. }),
"got: {err:?}"
);
}
#[test]
@@ -455,7 +466,10 @@ mod tests {
// Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert!(!args.demo, "demo is off unless --demo or the env var is given");
assert!(
!args.demo,
"demo is off unless --demo or the env var is given"
);
}
#[test]
@@ -484,7 +498,10 @@ mod tests {
}
// Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
assert!(
!demo_value_is_truthy(v),
"{v:?} should not enable demo mode"
);
}
}
@@ -493,7 +510,10 @@ mod tests {
// Make sure the path-vs-flag distinction is robust:
// unknown flags don't get silently swallowed as paths.
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
assert!(
matches!(&err, ArgsError::Unknown(s) if s == "--bogus"),
"got: {err:?}"
);
}
// ---- ADR-0054: --version / -V ----
+254 -210
View File
@@ -15,10 +15,10 @@
//! `app.rs`; this module owns the candidate computation.
use crate::dsl::grammar::IdentSource;
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command};
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::mode::Mode;
/// Composite literal candidates whose lexed shape is more than
@@ -275,11 +275,7 @@ pub struct Completion {
/// (case-insensitive starts-with), combined, sorted, and
/// deduplicated.
#[must_use]
pub fn candidates_at_cursor(
input: &str,
cursor: usize,
cache: &SchemaCache,
) -> Option<Completion> {
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
}
@@ -358,7 +354,11 @@ pub fn candidates_at_cursor_with_in_mode(
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) {
if pre
.expected
.iter()
.any(|e| matches!(e, Expectation::Flag(_)))
{
start = run;
}
}
@@ -473,22 +473,19 @@ pub fn candidates_at_cursor_with_in_mode(
// walk's `current_table_columns`; fall back to "the union of
// the look-ahead from_scope's bindings' columns" when leading
// produced no in-scope columns. Phase-1 DSL paths unaffected.
let lookahead_union_columns: Vec<TableColumn> =
if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| {
c.name.eq_ignore_ascii_case(&col.name)
}) {
out.push(col.clone());
}
let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
out.push(col.clone());
}
}
out
} else {
Vec::new()
};
}
out
} else {
Vec::new()
};
let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() {
None
} else {
@@ -507,30 +504,23 @@ pub fn candidates_at_cursor_with_in_mode(
// column list (the structural error path surfaces the
// unresolved-prefix message).
let prefix_qualifier = peek_back_qualifier(input, start);
let qualified_columns: Option<Vec<String>> = prefix_qualifier
.as_ref()
.map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
}
});
let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
}
});
let expected = if probe.expected.is_empty() {
expected_at(leading, mode)
@@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode(
Some(crate::dsl::grammar::HintMode::ProseOnly(_))
);
if partial_prefix.is_empty()
&& (prose_only_slot
|| (is_value_literal_signature(&expected) && !has_schema_ident))
&& (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
{
return None;
}
@@ -646,7 +635,13 @@ pub fn candidates_at_cursor_with_in_mode(
// shortid). The walker surfaces this as
// `Expectation::Ident { source: Types }`.
let type_names: Vec<String> = if expected.iter().any(|e| {
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
matches!(
e,
Expectation::Ident {
source: IdentSource::Types,
..
}
)
}) {
Type::all()
.iter()
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
// filtered like every other source; empty prefix offers the whole
// set. Tagged `CandidateKind::Function` for its own colour.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
let mut functions: Vec<String> = if has_sql_expr_slot {
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
@@ -741,9 +742,15 @@ pub fn candidates_at_cursor_with_in_mode(
// curated vocabulary is offered so a learner can discover `email` /
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
functions.extend(
crate::seed::KNOWN_GENERATORS
@@ -765,38 +772,36 @@ pub fn candidates_at_cursor_with_in_mode(
// (the `typing_over_diag` path) — keeps the alias from flashing as
// a bogus "unknown column" while typing. Mixed into `identifiers`
// so it sorts/dedups/colours uniformly with column candidates.
let alias_candidates: Vec<String> =
if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier =
binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
let alias_candidates: Vec<String> = if has_sql_expr_slot && prefix_qualifier.is_none() {
// Once the partial *exactly* matches an in-scope qualifier,
// discoverability is served — the learner has a whole alias
// in hand and now needs the "add `.column`" hint
// (`diagnostic.alias_used_as_column`), not sibling aliases
// that merely share the prefix. Offering them would also let
// the `typing_over_diag` path suppress that very hint. So in
// the exact-match case we emit no alias candidates and let
// the targeted diagnostic surface.
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&partial_prefix)
});
if partial_is_exact_alias {
Vec::new()
};
} else {
let mut out: Vec<String> = Vec::new();
for binding in resolution_from_scope {
let qualifier = binding.alias.as_deref().unwrap_or(binding.table.as_str());
if matches_prefix(qualifier)
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
{
out.push(qualifier.to_string());
}
}
out
}
} else {
Vec::new()
};
// Source 2: schema identifiers — accumulated across every
// matching schema-listable `Ident { source }` expectation.
@@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode(
let mut identifiers: Vec<String> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.flat_map(|source| {
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Second: table-name match in the active from_scope.
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Third: direct cte_bindings match (cte_alias.|).
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
// Fourth: a bare table name from the schema cache — DSL
// paths reach this for `from <Table>.<col>` shapes where
@@ -1287,7 +1278,13 @@ pub fn invalid_ident_at_cursor_in_mode(
// column. So `select Agx` warns at typing time again, while
// `select sum` does not.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
return None;
@@ -1318,9 +1315,15 @@ pub fn invalid_ident_at_cursor_in_mode(
// schema-column check below would never see it. A partial that
// prefix-matches a known generator is an in-progress name; anything
// else is an unknown generator → flag it `[ERR]` while typing.
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
if crate::seed::is_known_generator_prefix(partial) {
return None;
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
let sources: Vec<IdentSource> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.collect();
@@ -1412,13 +1413,15 @@ mod tests {
use pretty_assertions::assert_eq;
fn cands(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor(input, cursor, &SchemaCache::default())
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
candidates_at_cursor(input, cursor, cache)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
/// Simple-mode completion candidates — the DSL surface
@@ -1429,7 +1432,9 @@ mod tests {
/// Advanced mode surfaces the SQL grammar's completions instead.
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
.map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cand_kinds_with(
@@ -1438,10 +1443,7 @@ mod tests {
cache: &SchemaCache,
) -> Vec<(String, CandidateKind)> {
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates
.into_iter()
.map(|c| (c.text, c.kind))
.collect()
c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
})
}
@@ -1503,12 +1505,21 @@ mod tests {
// Simple-only (column, relationship, constraint).
let cs = cands("drop ", 5);
for kw in ["table", "index", "column", "relationship", "constraint"] {
assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}");
assert!(
cs.contains(&kw.to_string()),
"`drop ` should offer `{kw}`; got {cs:?}"
);
}
// Both-mode continuations block before the simple-only ones.
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
assert!(
pos("table") < pos("column"),
"Both block precedes Simple block: {cs:?}"
);
assert!(
pos("index") < pos("relationship"),
"Both block precedes Simple block: {cs:?}"
);
}
#[test]
@@ -1631,8 +1642,14 @@ mod tests {
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
.expect("a `-` at a flag position offers candidates");
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}");
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
assert!(
texts.contains(&"--create-fk"),
"should offer --create-fk: {texts:?}"
);
assert!(
!texts.contains(&"on"),
"must NOT offer `on` after a dash: {texts:?}"
);
assert_eq!(
c.replaced_range,
(input.len() - 1, input.len()),
@@ -1643,13 +1660,9 @@ mod tests {
#[test]
fn double_dash_replaces_both_dashes_on_accept() {
let input = "delete from T --";
let c = candidates_at_cursor_in_mode(
input,
input.len(),
&SchemaCache::default(),
Mode::Simple,
)
.expect("`--` offers the flag");
let c =
candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
.expect("`--` offers the flag");
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
assert_eq!(
c.replaced_range,
@@ -1668,9 +1681,7 @@ mod tests {
s.tables.push("T".into());
s.columns.push("x".into());
let input = "show data T where x = -5";
if let Some(c) =
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
{
if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
assert!(
!c.candidates.iter().any(|x| x.text.starts_with("--")),
"no flags at a value position: {:?}",
@@ -1715,8 +1726,8 @@ mod tests {
// App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set.
for expected in &[
"quit", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages", "undo", "redo", "copy",
"quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
"messages", "undo", "redo", "copy",
] {
assert!(
cs.contains(&expected.to_string()),
@@ -1943,7 +1954,10 @@ mod tests {
// opening a sub-shape) becomes a Tab candidate.
let input = "add column to table T";
let cs = cands(input, input.len());
assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}");
assert!(
cs.is_empty(),
"trailing-content punct should not surface: {cs:?}"
);
}
#[test]
@@ -1957,10 +1971,7 @@ mod tests {
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
}
fn schema_with_table(
table: &str,
columns: &[(&str, crate::dsl::types::Type)],
) -> SchemaCache {
fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
let mut cache = SchemaCache::default();
cache.tables.push(table.to_string());
let cols: Vec<TableColumn> = columns
@@ -2002,8 +2013,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
assert!(
cs.contains(&"o".to_string()),
"alias `o` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"z".to_string()),
"alias `z` must be offered; got {cs:?}"
);
}
#[test]
@@ -2015,8 +2032,14 @@ mod tests {
let cache = two_table_alias_cache();
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
assert!(
cs.contains(&"aa".to_string()),
"alias `aa` must be offered; got {cs:?}"
);
assert!(
cs.contains(&"ab".to_string()),
"alias `ab` must be offered; got {cs:?}"
);
// Exact-alias partial: the alias source steps aside.
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
@@ -2046,19 +2069,20 @@ mod tests {
// SchemaCache.columns has columns from many tables, but
// at `update Customers set ` only Customers' columns
// should appear.
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
// Pretend the global flat list has columns from a second
// table that aren't in Customers.
cache.columns.push("OrderTotal".to_string());
cache.columns.push("Stock".to_string());
cache
.table_columns
.insert("Orders".to_string(), vec![
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false },
]);
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn {
name: "OrderTotal".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
}],
);
cache.tables.push("Orders".to_string());
let cs = cands_with("update Customers set ", 21, &cache);
// Customers's columns should appear:
@@ -2079,10 +2103,7 @@ mod tests {
// *before* ORDER BY (the FROM's JOIN options, WHERE /
// GROUP BY / HAVING, set-ops). Those used to shove the
// columns off-screen.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by ";
let cs = cands_with(input, input.len(), &cache);
// The columns the user wants are offered:
@@ -2090,8 +2111,19 @@ mod tests {
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
// Preceding-clause keywords must not leak in:
for kw in [
"where", "group", "having", "join", "union", "intersect",
"except", "left", "right", "full", "cross", "inner", "as",
"where",
"group",
"having",
"join",
"union",
"intersect",
"except",
"left",
"right",
"full",
"cross",
"inner",
"as",
] {
assert!(
!cs.contains(&kw.to_string()),
@@ -2108,10 +2140,7 @@ mod tests {
// sort item the direction keywords surface as
// continuations (previously discarded at the Repeated
// boundary, so completion offered neither).
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
@@ -2123,10 +2152,7 @@ mod tests {
use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete
// projection item the `as` alias keyword surfaces.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
@@ -2153,16 +2179,13 @@ mod tests {
// ADR-0022 Amendment 2: at an expression position offering
// both column names and keywords, every column precedes
// every keyword so the names stay visible by default.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select * from Things where ";
let cs = cands_with(input, input.len(), &cache);
let pos = |needle: &str| {
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
panic!("{needle:?} not in candidates: {cs:?}")
})
cs.iter()
.position(|c| c == needle)
.unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
};
// Both columns come before any expression-start keyword.
let last_ident = pos("Name").max(pos("Qty"));
@@ -2176,13 +2199,9 @@ mod tests {
#[test]
fn update_where_offers_only_current_table_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("update Customers set Email='x' where ", 37, &cache);
let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2208,7 +2227,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
&[
("id", Type::Int),
("Email", Type::Text),
("Name", Type::Text),
],
);
let cs = cands_with("insert into Customers (", 23, &cache);
// The user is at Form A's column-list position. All
@@ -2222,10 +2245,7 @@ mod tests {
#[test]
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs = cands_with("insert into Customers (", 23, &cache);
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2239,13 +2259,9 @@ mod tests {
// table's columns. `OrderTotal` belongs to no table in
// this cache's `table_columns`, so it must not leak.
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("drop column from Customers: ", 28, &cache);
let cs = cands_with("drop column from Customers: ", 28, &cache);
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(
@@ -2271,8 +2287,8 @@ mod tests {
#[test]
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (0, 3));
assert_eq!(comp.partial_prefix, "cre");
assert_eq!(comp.candidates.len(), 1);
@@ -2282,8 +2298,8 @@ mod tests {
#[test]
fn cursor_at_word_boundary_has_empty_partial_prefix() {
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (7, 7));
assert_eq!(comp.partial_prefix, "");
}
@@ -2517,8 +2533,8 @@ mod tests {
// inside `Name`, and substituting any name there
// produces a complete command. No useful "next after
// name" hint.
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
.expect("should fire");
let t =
typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
assert_eq!(t.next_after_name, None);
}
@@ -2534,8 +2550,8 @@ mod tests {
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
// `show data Cust` plus a typo: `show data Custp`. No
// table starts with "Custp" → invalid.
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
.expect("should be invalid");
let invalid =
invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
assert_eq!(invalid.range, (10, 15));
assert_eq!(invalid.found, "Custp");
assert_eq!(invalid.source, IdentSource::Tables);
@@ -2600,7 +2616,11 @@ mod tests {
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
"NewName slot must not surface schema candidates; got {cs:?}"
);
assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword");
assert_eq!(
cs,
vec!["if".to_string()],
"only the advanced IF NOT EXISTS keyword"
);
}
fn keyword_cand(text: &str) -> Candidate {
@@ -2791,8 +2811,10 @@ mod tests {
let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion")
.candidates;
let count_entries: Vec<_> =
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
let count_entries: Vec<_> = cands
.iter()
.filter(|c| c.text.eq_ignore_ascii_case("count"))
.collect();
assert_eq!(
count_entries.len(),
1,
@@ -2805,7 +2827,9 @@ mod tests {
);
// A non-colliding function at the same slot is unaffected.
assert!(
cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
cands
.iter()
.any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
"non-colliding functions still surface; got {cands:?}",
);
}
@@ -2875,8 +2899,10 @@ mod tests {
let mut s = SchemaCache::default();
s.tables.push("OrderLines".into());
s.columns.push("count".into());
s.table_columns
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]);
s.table_columns.insert(
"OrderLines".into(),
vec![TableColumn::new("count", Type::Int)],
);
let input = "select sum(ol.count) from OrderLines ol";
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
assert!(
@@ -2938,15 +2964,35 @@ mod tests {
s.table_columns.insert(
"a".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
],
);
s.table_columns.insert(
"b".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "total".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
},
],
);
s
@@ -3191,5 +3237,3 @@ mod tests {
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
}
}
+1140 -796
View File
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -549,9 +549,7 @@ pub enum AppCommand {
/// word like `insert` / `create` / `show`, or `types`), the
/// focused detail for that command (or command group sharing
/// the entry word).
Help {
topic: Option<String>,
},
Help { topic: Option<String> },
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
@@ -580,7 +578,10 @@ pub enum AppCommand {
/// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from
/// the zip).
Import { path: String, target: Option<String> },
Import {
path: String,
target: Option<String>,
},
/// Switch the persistent input mode.
Mode { value: ModeValue },
/// Show or set the messages verbosity.
@@ -791,9 +792,7 @@ impl PartialEq for Operand {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
a == b
}
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
_ => false,
}
}
@@ -817,7 +816,9 @@ pub enum CompareOp {
/// a single row in the metadata table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationshipSelector {
Named { name: String },
Named {
name: String,
},
Endpoints {
parent_table: String,
parent_column: String,
@@ -1156,9 +1157,7 @@ impl Command {
parent_column,
child_table,
child_column,
} => format!(
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
),
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
},
// A constraint command's subject is the dotted
// `<table>.<column>` it acts on (ADR-0029 §2.2).
+41 -30
View File
@@ -9,8 +9,7 @@
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
Word,
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
};
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const IMPORT_TARGET: Node = Node::Hinted {
mode: HintMode::ForceProse("hint.ambient_typing_name"),
inner: &IMPORT_TARGET_IDENT,
};
const IMPORT_AS_TARGET: Node = Node::Seq(&[
Node::Word(Word::keyword("as")),
IMPORT_TARGET,
]);
const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
@@ -101,9 +97,9 @@ const MODE_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
@@ -119,9 +115,9 @@ const MESSAGES_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
@@ -271,7 +267,8 @@ pub static QUIT: CommandNode = CommandNode {
ast_builder: build_quit,
help_id: Some("app.quit"),
hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],};
usage_ids: &["parse.usage.quit"],
};
pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"),
@@ -279,7 +276,8 @@ pub static HELP: CommandNode = CommandNode {
ast_builder: build_help,
help_id: Some("app.help"),
hint_ids: &["help"],
usage_ids: &["parse.usage.help"],};
usage_ids: &["parse.usage.help"],
};
pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"),
@@ -288,7 +286,8 @@ pub static HINT: CommandNode = CommandNode {
help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_ids: &["hint"],
usage_ids: &["parse.usage.hint"],};
usage_ids: &["parse.usage.hint"],
};
pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"),
@@ -296,7 +295,8 @@ pub static REBUILD: CommandNode = CommandNode {
ast_builder: build_rebuild,
help_id: Some("app.rebuild"),
hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],};
usage_ids: &["parse.usage.rebuild"],
};
pub static VERSION: CommandNode = CommandNode {
entry: Word::keyword("version"),
@@ -304,7 +304,8 @@ pub static VERSION: CommandNode = CommandNode {
ast_builder: build_version,
help_id: Some("app.version"),
hint_ids: &["version"],
usage_ids: &["parse.usage.version"],};
usage_ids: &["parse.usage.version"],
};
pub static SAVE: CommandNode = CommandNode {
entry: Word::keyword("save"),
@@ -312,7 +313,8 @@ pub static SAVE: CommandNode = CommandNode {
ast_builder: build_save,
help_id: Some("app.save"),
hint_ids: &["save"],
usage_ids: &["parse.usage.save"],};
usage_ids: &["parse.usage.save"],
};
pub static NEW: CommandNode = CommandNode {
entry: Word::keyword("new"),
@@ -320,7 +322,8 @@ pub static NEW: CommandNode = CommandNode {
ast_builder: build_new,
help_id: Some("app.new"),
hint_ids: &["new"],
usage_ids: &["parse.usage.new"],};
usage_ids: &["parse.usage.new"],
};
pub static LOAD: CommandNode = CommandNode {
entry: Word::keyword("load"),
@@ -328,7 +331,8 @@ pub static LOAD: CommandNode = CommandNode {
ast_builder: build_load,
help_id: Some("app.load"),
hint_ids: &["load"],
usage_ids: &["parse.usage.load"],};
usage_ids: &["parse.usage.load"],
};
pub static EXPORT: CommandNode = CommandNode {
entry: Word::keyword("export"),
@@ -336,7 +340,8 @@ pub static EXPORT: CommandNode = CommandNode {
ast_builder: build_export,
help_id: Some("app.export"),
hint_ids: &["export"],
usage_ids: &["parse.usage.export"],};
usage_ids: &["parse.usage.export"],
};
pub static IMPORT: CommandNode = CommandNode {
entry: Word::keyword("import"),
@@ -344,7 +349,8 @@ pub static IMPORT: CommandNode = CommandNode {
ast_builder: build_import,
help_id: Some("app.import"),
hint_ids: &["import"],
usage_ids: &["parse.usage.import"],};
usage_ids: &["parse.usage.import"],
};
pub static MODE: CommandNode = CommandNode {
entry: Word::keyword("mode"),
@@ -352,7 +358,8 @@ pub static MODE: CommandNode = CommandNode {
ast_builder: build_mode,
help_id: Some("app.mode"),
hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],};
usage_ids: &["parse.usage.mode"],
};
pub static MESSAGES: CommandNode = CommandNode {
entry: Word::keyword("messages"),
@@ -360,7 +367,8 @@ pub static MESSAGES: CommandNode = CommandNode {
ast_builder: build_messages,
help_id: Some("app.messages"),
hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],};
usage_ids: &["parse.usage.messages"],
};
pub static UNDO: CommandNode = CommandNode {
entry: Word::keyword("undo"),
@@ -368,7 +376,8 @@ pub static UNDO: CommandNode = CommandNode {
ast_builder: build_undo,
help_id: Some("app.undo"),
hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],};
usage_ids: &["parse.usage.undo"],
};
pub static REDO: CommandNode = CommandNode {
entry: Word::keyword("redo"),
@@ -376,7 +385,8 @@ pub static REDO: CommandNode = CommandNode {
ast_builder: build_redo,
help_id: Some("app.redo"),
hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],};
usage_ids: &["parse.usage.redo"],
};
pub static COPY: CommandNode = CommandNode {
entry: Word::keyword("copy"),
@@ -384,4 +394,5 @@ pub static COPY: CommandNode = CommandNode {
ast_builder: build_copy,
help_id: Some("app.copy"),
hint_ids: &["copy"],
usage_ids: &["parse.usage.copy"],};
usage_ids: &["parse.usage.copy"],
};
+101 -78
View File
@@ -24,19 +24,17 @@
//! later swap that capture for the same typed slots used here, adding
//! live hints/highlighting.
use crate::dsl::command::{
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
};
use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
current_column_value, insert_target_columns,
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
insert_target_columns,
},
sql_delete, sql_insert, sql_select, sql_update,
};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// =================================================================
@@ -56,10 +54,10 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Table-name slot variant that populates
@@ -75,10 +73,10 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
@@ -95,10 +93,7 @@ const SHOW_DATA_NODES: &[Node] = &[
];
const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
const SHOW_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
// `show tables` / `show relationships` / `show indexes` — the
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
writes_cte_name: false,
writes_projection_alias: false,
};
const SHOW_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
const SHOW_CHOICES: &[Node] = &[
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: true,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static INSERT_COMMA: Node = Node::Punct(',');
@@ -224,8 +218,7 @@ fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node {
/// or an identifier-shaped token (a column name) returns false.
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
};
let p = skip_whitespace(source, pos);
if p >= source.len() {
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
return FALLBACK_VALUE_LIST;
};
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if arity_ok {
Node::DynamicSubgrammar(column_value_list)
} else {
@@ -320,8 +317,7 @@ const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[
];
const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
const INSERT_AFTER_TABLE_CHOICES: &[Node] =
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
const INSERT_NODES: &[Node] = &[
@@ -349,10 +345,10 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Column-name slot in `set col = …` — resolves the column's
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Value slot resolved at walk time from
@@ -376,11 +372,7 @@ writes_projection_alias: false,
/// value-literal choice when no current_column is bound.
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
SET_COLUMN,
Node::Punct('='),
PER_COLUMN_VALUE,
];
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
inner: &UPDATE_ASSIGNMENT,
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const SEED_SET_CLAUSE_NODES: &[Node] =
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
const SEED_NODES: &[Node] = &[
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("in") => {
*i += 1; // `in`
// `(`
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Punct('('))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Punct('('))
) {
*i += 1;
}
let mut values = Vec::new();
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("between") => {
*i += 1; // `between`
let low = seed_take_value(region, i, column)?;
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Word("and"))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Word("and"))
) {
*i += 1;
}
let high = seed_take_value(region, i, column)?;
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
*i += 1; // `as`
let gen_item = region
.get(*i)
.filter(|t| matches!(t.kind, MatchedKind::Ident { role: "seed_generator", .. }))
.filter(|t| {
matches!(
t.kind,
MatchedKind::Ident {
role: "seed_generator",
..
}
)
})
.ok_or_else(|| seed_set_error(column))?;
*i += 1;
Ok(SeedOverrideKind::Generator(gen_item.text.clone()))
@@ -1085,7 +1090,15 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
let table_idx = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Ident { role: "table_name", .. }))
.position(|i| {
matches!(
&i.kind,
MatchedKind::Ident {
role: "table_name",
..
}
)
})
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing table".to_string())],
@@ -1141,7 +1154,10 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
if columns.is_empty() {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "expected column names in `insert into T (…)`".to_string())],
args: vec![(
"detail",
"expected column names in `insert into T (…)`".to_string(),
)],
});
}
// Find the `values` keyword and the next `(` — the values
@@ -1247,9 +1263,7 @@ fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, Validation
})
}
fn collect_assignments(
path: &MatchedPath,
) -> Result<Vec<(String, Value)>, ValidationError> {
fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
let mut out = Vec::new();
let mut iter = path.items.iter();
while let Some(item) = iter.next() {
@@ -1495,9 +1509,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
let row_source = path
.items
.iter()
.find(|item| {
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
})
.find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
.map(|item| {
let end = tail_start.unwrap_or(source.len());
source[item.span.0..end]
@@ -1805,7 +1817,8 @@ pub static SHOW: CommandNode = CommandNode {
"parse.usage.show_indexes",
"parse.usage.show_relationship",
"parse.usage.show_index",
],};
],
};
pub static SEED: CommandNode = CommandNode {
entry: Word::keyword("seed"),
@@ -1823,7 +1836,8 @@ pub static INSERT: CommandNode = CommandNode {
help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],};
usage_ids: &["parse.usage.insert"],
};
pub static UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"),
@@ -1831,7 +1845,8 @@ pub static UPDATE: CommandNode = CommandNode {
ast_builder: build_update,
help_id: Some("data.update"),
hint_ids: &["update"],
usage_ids: &["parse.usage.update"],};
usage_ids: &["parse.usage.update"],
};
pub static DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"),
@@ -1839,7 +1854,8 @@ pub static DELETE: CommandNode = CommandNode {
ast_builder: build_delete,
help_id: Some("data.delete"),
hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],};
usage_ids: &["parse.usage.delete"],
};
pub static REPLAY: CommandNode = CommandNode {
entry: Word::keyword("replay"),
@@ -1847,7 +1863,8 @@ pub static REPLAY: CommandNode = CommandNode {
ast_builder: build_replay,
help_id: Some("data.replay"),
hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],};
usage_ids: &["parse.usage.replay"],
};
pub static EXPLAIN: CommandNode = CommandNode {
entry: Word::keyword("explain"),
@@ -1855,7 +1872,8 @@ pub static EXPLAIN: CommandNode = CommandNode {
ast_builder: build_explain,
help_id: Some("data.explain"),
hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],};
usage_ids: &["parse.usage.explain"],
};
/// `explain` over advanced-mode SQL (ADR-0039).
///
@@ -1875,7 +1893,8 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
// precedent; otherwise `note_help` would print `explain` twice.
help_id: None,
hint_ids: &["explain_sql"],
usage_ids: &[],};
usage_ids: &[],
};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
///
@@ -1891,7 +1910,8 @@ pub static SELECT: CommandNode = CommandNode {
ast_builder: build_select,
help_id: None,
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],};
usage_ids: &["parse.usage.select"],
};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
///
@@ -1906,7 +1926,8 @@ pub static WITH: CommandNode = CommandNode {
ast_builder: build_select,
help_id: None,
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],};
usage_ids: &["parse.usage.with"],
};
/// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
@@ -1993,7 +2014,11 @@ mod explain_tests {
#[test]
fn explain_show_data_carries_where_and_limit_through() {
match explain_inner("explain show data Customers where id = 1 limit 5") {
Command::ShowData { name, filter, limit } => {
Command::ShowData {
name,
filter,
limit,
} => {
assert_eq!(name, "Customers");
assert!(filter.is_some(), "where clause should survive");
assert_eq!(limit, Some(5));
@@ -2052,9 +2077,7 @@ mod explain_tests {
/// Advanced-mode counterpart of `explain_inner`.
fn explain_inner_adv(input: &str) -> Command {
match parse_command_in_mode(input, Mode::Advanced)
.expect("advanced explain should parse")
{
match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
Command::Explain { query } => *query,
other => panic!("expected Command::Explain, got {other:?}"),
}
@@ -2085,7 +2108,9 @@ mod explain_tests {
#[test]
fn explain_sql_insert_wraps_a_sql_insert() {
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
Command::SqlInsert { sql, target_table, .. } => {
Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
}
@@ -2096,7 +2121,9 @@ mod explain_tests {
#[test]
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
Command::SqlUpdate { sql, target_table, .. } => {
Command::SqlUpdate {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
}
@@ -2107,7 +2134,9 @@ mod explain_tests {
#[test]
fn explain_sql_delete_wraps_a_sql_delete() {
match explain_inner_adv("explain delete from Customers where id = 1") {
Command::SqlDelete { sql, target_table, .. } => {
Command::SqlDelete {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "delete from Customers where id = 1");
}
@@ -2148,11 +2177,7 @@ mod explain_tests {
fn explain_does_not_cover_ddl() {
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
// out of scope); there is no SQL DDL branch under explain.
assert!(parse_command_in_mode(
"explain create table T (id int)",
Mode::Advanced,
)
.is_err());
assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
}
#[test]
@@ -2165,9 +2190,8 @@ mod explain_tests {
use crate::completion::candidates_at_cursor_in_mode;
let schema = crate::completion::SchemaCache::default();
let input = "explain ";
let completion =
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let names: Vec<&str> = completion
.candidates
.iter()
@@ -2178,4 +2202,3 @@ mod explain_tests {
}
}
}
+246 -166
View File
@@ -16,11 +16,11 @@ use crate::dsl::command::{
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
use crate::dsl::value::Value;
/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
@@ -39,12 +39,12 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -63,12 +63,12 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME: Node = Node::Ident {
@@ -76,12 +76,12 @@ const COLUMN_NAME: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
@@ -89,12 +89,12 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -106,12 +106,12 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
@@ -119,12 +119,12 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
@@ -192,8 +189,10 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
// `table` keyword (not the Optional) keeps the element/dispatch
// matching honest.
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
Node::Word(Word::keyword("if")),
Node::Word(Word::keyword("exists")),
];
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
@@ -257,9 +256,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -270,9 +269,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
@@ -286,9 +285,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -299,9 +298,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
@@ -317,10 +316,7 @@ const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
const DROP_RELATIONSHIP_NODES: &[Node] = &[
Node::Word(Word::keyword("relationship")),
DR_SELECTOR,
];
const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
// =================================================================
@@ -341,18 +337,20 @@ const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] =
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
const DROP_CHOICES: &[Node] = &[
DROP_COLUMN,
DROP_RELATIONSHIP,
DROP_TABLE,
DROP_INDEX,
DROP_CONSTRAINT,
];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
@@ -474,10 +471,7 @@ const AR_CHILD_NODES: &[Node] = &[
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
@@ -501,10 +495,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================
const AI_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[
@@ -537,9 +528,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -563,10 +554,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
// ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None,
@@ -732,8 +720,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let (not_null, unique, default, check) =
collect_column_constraints(path)?;
let (not_null, unique, default, check) = collect_column_constraints(path)?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
@@ -949,7 +936,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
} else {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
args: vec![(
"detail",
"drop constraint needs a constraint kind".to_string(),
)],
});
};
Ok(Command::DropConstraint {
@@ -981,7 +971,8 @@ pub static DROP: CommandNode = CommandNode {
"parse.usage.drop_relationship",
"parse.usage.drop_index",
"parse.usage.drop_constraint",
],};
],
};
pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"),
@@ -1003,7 +994,8 @@ pub static ADD: CommandNode = CommandNode {
"parse.usage.add_relationship",
"parse.usage.add_index",
"parse.usage.add_constraint",
],};
],
};
pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"),
@@ -1011,7 +1003,8 @@ pub static RENAME: CommandNode = CommandNode {
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],};
usage_ids: &["parse.usage.rename_column"],
};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
@@ -1019,7 +1012,8 @@ pub static CHANGE: CommandNode = CommandNode {
ast_builder: build_change_column,
help_id: Some("ddl.change"),
hint_ids: &["change_column"],
usage_ids: &["parse.usage.change_column"],};
usage_ids: &["parse.usage.change_column"],
};
// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
@@ -1034,9 +1028,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -1074,8 +1068,12 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT,
UNIQUE_CONSTRAINT,
DEFAULT_CONSTRAINT,
CHECK_CONSTRAINT,
];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's
@@ -1114,8 +1112,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
// `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] =
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
const ADD_CONSTRAINT_NODES: &[Node] = &[
@@ -1145,9 +1142,9 @@ const COL_SPEC_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX,
@@ -1275,10 +1272,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, Vali
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1380,7 +1381,8 @@ pub static CREATE: CommandNode = CommandNode {
ast_builder: build_create_table,
help_id: Some("ddl.create"),
hint_ids: &["create_table"],
usage_ids: &["parse.usage.create_table"],};
usage_ids: &["parse.usage.create_table"],
};
// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
@@ -1506,11 +1508,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
while let Some(item) = items.next() {
match &item.kind {
// A column name stashes until its type finalises the spec.
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
// Single-word type — resolve through the SQL alias map.
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1533,7 +1539,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
column_open = true;
}
// A table-level `PRIMARY KEY (col, …)` column reference.
MatchedKind::Ident { role: "pk_column", .. } => {
MatchedKind::Ident {
role: "pk_column", ..
} => {
primary_key.push(item.text.clone());
}
// `not null` column constraint (only once a column exists;
@@ -1557,7 +1565,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
let mut cols: Vec<String> = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => {
MatchedKind::Ident {
role: "unique_column",
..
} => {
cols.push(it.text.clone());
items.next();
}
@@ -1575,7 +1586,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// column's flag (round-trips via the single-column
// path); composite (or a name not among the
// columns) becomes a constraint.
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
match columns
.iter_mut()
.find(|c| cols.len() == 1 && c.name == cols[0])
{
Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols),
None => {}
@@ -1588,16 +1602,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// the most recent column) or the table-level clause (whose
// `pk_column` idents follow and are collected above).
MatchedKind::Word("primary") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
// Table-level `PRIMARY KEY (…)` is followed by `(`
// (then `pk_column` idents, collected above);
// column-level `PRIMARY KEY` is not, and marks the
// most-recent column.
let table_level = matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Punct('('))
);
let table_level =
matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
if !table_level && let Some(last) = columns.last() {
primary_key.push(last.name.clone());
}
@@ -1647,12 +1662,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4).
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
foreign_keys.push(consume_fk_reference(
&mut items,
None,
vec![child_column],
true,
));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next(); // `key`
}
// `( <child column> [, <child column>]* )` — a compound
@@ -1674,7 +1697,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
items.next();
}
// `references <parent> …`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
let fk =
@@ -1859,13 +1885,19 @@ where
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
Some(MatchedKind::Word("set")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
}
ReferentialAction::SetNull
}
Some(MatchedKind::Word("no")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("action"))
) {
items.next();
}
ReferentialAction::NoAction
@@ -1933,11 +1965,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("unique")),
Node::Word(Word::keyword("index")),
];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] =
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
@@ -2104,8 +2137,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
NEW_COLUMN_NAME,
];
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
@@ -2132,8 +2164,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[
super::sql_create_table::SQL_TYPE,
];
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
static AT_AC_NOT_NULL_NODES: &[Node] =
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
static AT_AC_NOT_NULL_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
];
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
Node::Word(Word::keyword("data")),
@@ -2149,8 +2183,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
@@ -2258,10 +2291,14 @@ fn build_alter_add_column_spec(
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -2280,7 +2317,10 @@ fn build_alter_add_column_spec(
spec = Some(ColumnSpec::new(name, Type::Real));
}
MatchedKind::Word("not") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
if let Some(s) = spec.as_mut() {
s.not_null = true;
@@ -2326,11 +2366,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_type", .. } => {
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?);
MatchedKind::Ident {
role: "col_type", ..
} => {
ty = Some(
Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?,
);
}
MatchedKind::Word("double") => {
if matches!(
@@ -2379,7 +2423,10 @@ fn build_alter_column_attr(
message_key: "parse.error_wrapper",
args: vec![("detail", "set default needs a value".to_string())],
})?;
AlterTableAction::SetColumnDefault { column, default_sql }
AlterTableAction::SetColumnDefault {
column,
default_sql,
}
}
(false, true) => AlterTableAction::DropColumnDefault { column },
(true, false) => AlterTableAction::SetColumnNotNull { column },
@@ -2495,10 +2542,7 @@ fn build_alter_add_table_constraint(
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
/// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql(
path: &MatchedPath,
source: &str,
) -> Result<String, ValidationError> {
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
if matches!(item.kind, MatchedKind::Word("check"))
@@ -2528,7 +2572,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
items.next();
}
items.next(); // `foreign`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
@@ -2548,7 +2595,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
@@ -2626,7 +2676,10 @@ mod constraint_tests {
fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
assert!(
cols.iter()
.all(|c| !c.not_null && !c.unique && c.default.is_none())
);
}
#[test]
@@ -2651,7 +2704,9 @@ mod constraint_tests {
#[test]
fn add_column_parses_a_unique_constraint() {
match parse_command("add column to T: email (text) unique").expect("parse") {
Command::AddColumn { unique, not_null, .. } => {
Command::AddColumn {
unique, not_null, ..
} => {
assert!(unique);
assert!(!not_null);
}
@@ -2682,9 +2737,7 @@ mod constraint_tests {
fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly.
let cols = create_columns(
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
assert!(cols[0].check.is_some());
}
@@ -2731,8 +2784,7 @@ mod constraint_tests {
#[test]
fn add_constraint_check_parses() {
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
{
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") {
Command::AddConstraint {
column, constraint, ..
} => {
@@ -2826,8 +2878,11 @@ mod sql_drop_table_tests {
Command::DropColumn { .. }
));
assert!(matches!(
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
.expect("parses"),
parse_command_in_mode(
"drop relationship Customers_id_to_Orders_CustId",
Mode::Advanced
)
.expect("parses"),
Command::DropRelationship { .. }
));
}
@@ -2932,7 +2987,13 @@ mod sql_create_index_tests {
columns,
unique,
if_not_exists,
} => Ci { name, table, columns, unique, if_not_exists },
} => Ci {
name,
table,
columns,
unique,
if_not_exists,
},
other => panic!("expected SqlCreateIndex, got {other:?}"),
}
}
@@ -3134,7 +3195,9 @@ mod sql_alter_table_tests {
// The target slot carries the `reject_internal_table` validator
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
// before submit — engine-neutral, not a raw engine error.
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
assert!(
parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()
);
}
#[test]
@@ -3213,7 +3276,10 @@ mod sql_alter_table_tests {
// alias map still applies through the synonym
assert!(matches!(
alter("alter table T alter column n set data type double precision").1,
AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. }
AlterTableAction::AlterColumnType {
ty: crate::dsl::types::Type::Real,
..
}
));
}
@@ -3238,7 +3304,10 @@ mod sql_alter_table_tests {
#[test]
fn alter_column_set_default_captures_raw_expr() {
match alter("alter table T alter column qty set default 0").1 {
AlterTableAction::SetColumnDefault { column, default_sql } => {
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => {
assert_eq!(column, "qty");
assert_eq!(default_sql, "0");
}
@@ -3317,7 +3386,9 @@ mod sql_alter_table_tests {
match alter("alter table T add check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
assert!(
matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")
);
}
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
}
@@ -3335,7 +3406,9 @@ mod sql_alter_table_tests {
match alter("alter table T add unique (a, b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
assert!(
matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])
);
}
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
}
@@ -3352,7 +3425,9 @@ mod sql_alter_table_tests {
)
.expect_err("a named UNIQUE constraint is refused");
assert!(
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
err.to_string()
.to_lowercase()
.contains("unique constraint cannot be named"),
"expected the builder's named-UNIQUE refusal, got: {err}"
);
}
@@ -3364,7 +3439,9 @@ mod sql_alter_table_tests {
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
.expect_err("ADD PRIMARY KEY is refused");
assert!(
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
err.to_string()
.to_lowercase()
.contains("primary key is fixed at creation"),
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
);
}
@@ -3392,7 +3469,10 @@ mod sql_alter_table_tests {
assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
assert_eq!(
fk.parent_columns, None,
"bare reference resolves at execution"
);
}
other => panic!("expected ForeignKey, got {other:?}"),
}
+24 -35
View File
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Operand alternatives. The literal keywords (`null` / `true`
@@ -126,8 +126,7 @@ fn where_rhs_operand(ctx: &WalkContext) -> Node {
// the leak is per distinct column (the walker
// memoizes `DynamicSubgrammar` resolution on
// `current_column`), not per keystroke.
let leaked: &'static str =
Box::leak(col.name.clone().into_boxed_str());
let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
Node::TypedValueSlot {
ty: col.user_type,
column_name: Some(leaked),
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
Node::Subgrammar(&OR_EXPR),
Node::Punct(')'),
];
static BOOL_PRIMARY_CHOICES: &[Node] = &[
Node::Seq(PAREN_GROUP_NODES),
Node::Subgrammar(&PREDICATE),
];
static BOOL_PRIMARY_CHOICES: &[Node] =
&[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
/// `not_expr := NOT not_expr | bool_primary`.
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&BOOL_PRIMARY),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
/// `and_expr := not_expr ( AND not_expr )*`.
@@ -296,10 +290,7 @@ static AND_EXPR: Node = Node::Seq(AND_EXPR_NODES);
/// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
/// point. `update` / `delete` / `show data` reference this
/// through `Node::Subgrammar(&OR_EXPR)`.
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
let span = item.span;
let literal = |value: Value| Operand::Literal { value, span };
match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => {
Ok(Operand::Column { name: item.text.clone(), span })
}
MatchedKind::Ident {
role: "expr_column",
..
} => Ok(Operand::Column {
name: item.text.clone(),
span,
}),
MatchedKind::Word("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => {
Ok(literal(Value::Number(item.text.clone())))
}
MatchedKind::StringLit => {
Ok(literal(Value::Text(item.text.clone())))
}
MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
_ => Err(drift_error("expected a column or literal operand")),
}
}
@@ -591,8 +582,7 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
match result {
NodeWalkResult::Matched { end, .. } => {
assert!(
@@ -730,8 +720,7 @@ mod tests {
negated: false,
}),
);
let Expr::Predicate(Predicate::Like { negated, .. }) =
parse_expr("Name not like 'A%'")
let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
else {
panic!("expected a negated Like");
};
@@ -794,16 +783,16 @@ mod tests {
fn nested_parentheses_round_trip() {
// Exercises the Subgrammar recursion a few levels deep.
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))");
assert!(matches!(expr, Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)));
assert!(matches!(
expr,
Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)
));
}
#[test]
fn case_insensitive_keywords() {
// Keywords fold case; the built tree is identical.
assert_eq!(
parse_expr("a = 1 AND b = 2"),
parse_expr("a = 1 and b = 2"),
);
assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
assert_eq!(
parse_expr("Email IS NOT NULL"),
parse_expr("Email is not null"),
+20 -24
View File
@@ -27,9 +27,9 @@ pub mod data;
pub mod ddl;
pub mod expr;
pub mod shared;
pub mod sql_expr;
pub mod sql_create_table;
pub mod sql_delete;
pub mod sql_expr;
pub mod sql_insert;
pub mod sql_select;
pub mod sql_update;
@@ -328,9 +328,7 @@ pub enum Node {
/// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce
/// per-type integer/decimal rules).
NumberLit {
validator: Option<NumberValidator>,
},
NumberLit { validator: Option<NumberValidator> },
/// A literal byte sequence at this position — matches
/// bytes verbatim (whitespace-skipped) with a lookahead so
/// `1` doesn't half-match `12` and `n` doesn't half-match
@@ -701,7 +699,11 @@ fn selected_nodes_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect()
};
if selected.is_empty() { candidates } else { selected }
if selected.is_empty() {
candidates
} else {
selected
}
}
/// The single usage template most relevant to `source`, when
@@ -724,10 +726,7 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
/// disambiguates the single most-relevant usage key from the
/// mode-selected key set.
#[must_use]
pub fn usage_key_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
@@ -755,7 +754,10 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
}
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it; its key ends `…m2n`.
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
if source[after..]
.get(..3)
.is_some_and(|s| s.eq_ignore_ascii_case("m:n"))
{
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`, `index`,
@@ -770,8 +772,7 @@ fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
/// which read the same data through the legacy `usage::REGISTRY`.
#[must_use]
pub fn entry_words_alphabetised() -> Vec<&'static str> {
let mut words: Vec<&'static str> =
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
words.sort_unstable();
words.dedup();
words
@@ -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
/// dispatcher picks among them by the active input mode.
#[must_use]
pub fn commands_for_entry_word(
word: &str,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
REGISTRY
.iter()
.enumerate()
@@ -1010,7 +1009,10 @@ mod hint_key_tests {
];
for c in classes {
let key = format!("hint.err.{c}.what");
assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`");
assert!(
cat.get(&key).is_some(),
"missing tier-3 error block `{key}`"
);
}
}
@@ -1098,10 +1100,7 @@ mod usage_key_tests {
let cases = [
("add column to T: c (int)", "parse.usage.add_column"),
("add index on T (c)", "parse.usage.add_index"),
(
"add constraint unique to T.c",
"parse.usage.add_constraint",
),
("add constraint unique to T.c", "parse.usage.add_constraint"),
(
"drop constraint check from T.c",
"parse.usage.drop_constraint",
@@ -1118,10 +1117,7 @@ mod usage_key_tests {
("drop table T", "parse.usage.drop_table"),
("drop column from table T: c", "parse.usage.drop_column"),
("drop index i", "parse.usage.drop_index"),
(
"drop relationship r",
"parse.usage.drop_relationship",
),
("drop relationship r", "parse.usage.drop_relationship"),
("show data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed
+17 -24
View File
@@ -7,8 +7,8 @@
use crate::completion::TableColumn;
use crate::dsl::grammar::{
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
NumberValidator, ValidationError, Word,
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
Word,
};
use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext;
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
.join(", ");
Err(ValidationError {
message_key: "parse.custom.unknown_type",
args: vec![
("found", value.to_string()),
("expected", expected),
],
args: vec![("found", value.to_string()), ("expected", expected)],
})
}
}
@@ -51,12 +48,12 @@ pub const TYPE_SLOT: Node = Node::Ident {
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// --- Qualified column reference (`<Table>.<Column>`) --------------
@@ -70,9 +67,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -83,9 +80,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
@@ -313,9 +310,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
&TEXT_SLOT_INNER
}
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER,
}
}
@@ -397,9 +392,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
/// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>(
ctx: &'c WalkContext<'_>,
) -> Option<Vec<&'c TableColumn>> {
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() {
return None;
+72 -22
View File
@@ -405,8 +405,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
// / `foreign`) that disambiguates it from a column name. (A column
// literally named with one of those keywords is therefore unavailable,
// the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] =
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
static ELEMENT_CHOICES: &[Node] = &[
TABLE_PK,
TABLE_UNIQUE,
TABLE_CHECK,
TABLE_FK_NAMED,
TABLE_FK,
COLUMN_DEF,
];
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
// position (`create table T (` and after every `,`) surfaces a prose
@@ -495,18 +501,31 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_CREATE_TABLE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_CREATE_TABLE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
fn good(input: &str) {
assert!(walks(input), "{input:?} should be a valid CREATE TABLE tail");
assert!(
walks(input),
"{input:?} should be a valid CREATE TABLE tail"
);
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete CREATE TABLE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete CREATE TABLE tail"
);
}
#[test]
@@ -638,7 +657,9 @@ mod tests {
good("table t (id int, ref int references other(id))");
good("table t (id int, ref int references other)"); // bare ref
good("table t (id int, ref int references other(id) on delete cascade)");
good("table t (id int, ref int references other(id) on update set null on delete restrict)");
good(
"table t (id int, ref int references other(id) on update set null on delete restrict)",
);
good("table t (id int, ref int, foreign key (ref) references other(id))");
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
good(
@@ -691,7 +712,10 @@ mod builder_tests {
assert_eq!(name, "t");
assert_eq!(
cols,
vec![("id".to_string(), Type::Int), ("name".to_string(), Type::Text)]
vec![
("id".to_string(), Type::Int),
("name".to_string(), Type::Text)
]
);
assert!(pk.is_empty(), "no PK declared");
assert!(!ine);
@@ -740,7 +764,10 @@ mod builder_tests {
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
assert_eq!(
cols,
vec![("a".to_string(), Type::Text), ("b".to_string(), Type::Decimal)]
vec![
("a".to_string(), Type::Text),
("b".to_string(), Type::Decimal)
]
);
}
@@ -780,8 +807,7 @@ mod builder_tests {
fn redundant_constraints_deduped_off_sole_pk_column() {
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
// and silently drops the flags off the sole PK column.
match parse_command("create table t (id int primary key not null unique)")
.expect("parses")
match parse_command("create table t (id int primary key not null unique)").expect("parses")
{
Command::SqlCreateTable {
columns,
@@ -944,8 +970,7 @@ mod builder_tests {
// depth 2, not an element boundary, so the following `check`
// is still column-level. A naive "reset on any comma" would
// misclassify it as table-level (the §4.2 probe).
let (cols, checks) =
parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
assert!(checks.is_empty(), "no table-level CHECK was produced");
}
@@ -977,8 +1002,7 @@ mod builder_tests {
fn table_check_before_a_later_column_is_table_level() {
// A CHECK element that appears between columns (not after a
// column's type) is table-level even though more columns follow.
let (cols, checks) =
parse_sct_checks("create table t (a int, check (a > 0), b int)");
let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
assert_eq!(checks, vec!["a > 0".to_string()]);
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
}
@@ -1004,7 +1028,10 @@ mod builder_tests {
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction);
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
assert!(
fk.inline,
"a column-level `references` is an inline FK (ADR-0043 D4)"
);
}
#[test]
@@ -1012,14 +1039,19 @@ mod builder_tests {
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
// carry a multi-column reference and never triggers the inline
// "use the table-level form" hint (ADR-0043 D4).
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
}
#[test]
fn bare_inline_reference_has_no_parent_column() {
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
assert_eq!(
fks[0].parent_columns, None,
"bare REFERENCES — resolved at execution"
);
assert_eq!(fks[0].parent_table, "parent");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
}
@@ -1047,8 +1079,9 @@ mod builder_tests {
#[test]
fn table_level_foreign_key_captured() {
let fks =
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert_eq!(fks.len(), 1);
assert_eq!(fks[0].name, None);
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
@@ -1073,8 +1106,20 @@ mod builder_tests {
foreign key (a) references p(id), foreign key (b) references q(id))",
);
assert_eq!(fks.len(), 2);
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
assert_eq!(
(
fks[0].child_columns[0].as_str(),
fks[0].parent_table.as_str()
),
("a", "p")
);
assert_eq!(
(
fks[1].child_columns[0].as_str(),
fks[1].parent_table.as_str()
),
("b", "q")
);
}
#[test]
@@ -1108,7 +1153,12 @@ mod builder_tests {
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
// the column-level CHECK still attaches to `pid`
assert_eq!(
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
columns
.iter()
.find(|c| c.name == "pid")
.unwrap()
.check_sql
.as_deref(),
Some("pid > 0")
);
// the table-level CHECK is captured separately
+12 -2
View File
@@ -82,7 +82,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_DELETE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -93,7 +100,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete DELETE tail"
);
}
#[test]
+31 -63
View File
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
// =================================================================
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static SQL_OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&PREDICATE),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// =================================================================
@@ -156,10 +150,7 @@ static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// needs. ADR-0026's DSL grammar made the tail mandatory because it
// forbade a bare column as a boolean; SQL does not.
static PREDICATE_NODES: &[Node] = &[
Node::Subgrammar(&ADDITIVE),
Node::Optional(&PREDICATE_TAIL),
];
static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
// ---- predicate_tail branches ------------------------------------
/// `cmp_op additive`.
static COMPARE_FORM_NODES: &[Node] = &[
Node::Choice(CMP_OP_CHOICES),
Node::Subgrammar(&ADDITIVE),
];
static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
/// `IS [NOT] NULL`.
static IS_NULL_NODES: &[Node] = &[
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
// additive := multiplicative ( ( + | - | || ) multiplicative )*
// =================================================================
static ADD_OP_CHOICES: &[Node] = &[
Node::Punct('+'),
Node::Punct('-'),
Node::Literal("||"),
];
static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
static ADD_TAIL_NODES: &[Node] = &[
Node::Choice(ADD_OP_CHOICES),
Node::Subgrammar(&MULTIPLICATIVE),
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
// multiplicative := unary ( ( * | / | % ) unary )*
// =================================================================
static MUL_OP_CHOICES: &[Node] = &[
Node::Punct('*'),
Node::Punct('/'),
Node::Punct('%'),
];
static MUL_TAIL_NODES: &[Node] = &[
Node::Choice(MUL_OP_CHOICES),
Node::Subgrammar(&UNARY),
];
static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
static MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
static MULTIPLICATIVE_NODES: &[Node] = &[
Node::Subgrammar(&UNARY),
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
// =================================================================
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
static UNARY_SIGN_NODES: &[Node] = &[
Node::Choice(SIGN_CHOICES),
Node::Subgrammar(&UNARY),
];
static UNARY_CHOICES: &[Node] = &[
Node::Seq(UNARY_SIGN_NODES),
Node::Subgrammar(&PRIMARY),
];
static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
static UNARY: Node = Node::Choice(UNARY_CHOICES);
// =================================================================
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
Node::Optional(&ELSE_CLAUSE),
Node::Word(Word::keyword("end")),
];
static CASE_BODY_CHOICES: &[Node] = &[
Node::Seq(SEARCHED_CASE_NODES),
Node::Seq(SIMPLE_CASE_NODES),
];
static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
static CASE_NODES: &[Node] = &[
Node::Word(Word::keyword("case")),
Node::Choice(CASE_BODY_CHOICES),
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
Node::Punct('.'),
QUALIFIED_REF_IDENT,
];
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
Node::Seq(QUALIFIED_REF_TAIL_NODES),
@@ -531,7 +496,10 @@ mod tests {
/// Assert `input` is *not* a complete SQL expression.
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete expression");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete expression"
);
}
#[test]
@@ -643,13 +611,13 @@ mod tests {
#[test]
fn malformed_expressions_do_not_walk() {
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
}
#[test]
@@ -680,9 +648,9 @@ mod tests {
// The optional tail dispatches `.identifier` (qualified
// ref) vs `(args)` (function call) by first token — a
// bare ident remains a column ref.
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
}
#[test]
+31 -8
View File
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
listed
.iter()
.filter_map(|name| {
table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned()
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.cloned()
})
.collect()
},
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
let cols = target_value_columns(ctx);
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if !cols.is_empty() && arity_ok {
Node::DynamicSubgrammar(sql_value_list)
} else {
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
/// `update`) disambiguates, so a non-match of branch 0 is a clean
/// `NoMatch` that falls through to branch 1.
static DO_ACTION_CHOICES: &[Node] =
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)];
static DO_ACTION_CHOICES: &[Node] = &[
Node::Word(Word::keyword("nothing")),
Node::Seq(DO_UPDATE_NODES),
];
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
@@ -361,7 +370,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_INSERT_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_INSERT_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -372,7 +388,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete INSERT tail"
);
}
#[test]
@@ -418,8 +437,12 @@ mod tests {
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9).
good("into t (id, name) values (1, 'x') on conflict (id) do nothing");
good("into t (id, name) values (1, 'x') on conflict do nothing");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0");
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
);
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0",
);
// Multi-column conflict target + multi-assignment DO UPDATE.
good("into t (a, b) values (1, 2) on conflict (a, b) do update set b = excluded.b, a = 9");
// ON CONFLICT composes with RETURNING (order: row source,
+62 -97
View File
@@ -141,8 +141,15 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]);
/// suffix keywords. `as` is not listed — the AS-form alias is a
/// separate `Choice` branch that fires before the lookahead.
const PROJECTION_FOLLOW_SET: &[&str] = &[
"from", "where", "group", "order", "having", "limit",
"union", "intersect", "except",
"from",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
// projection item's bare alias — so a no-FROM SELECT row source
@@ -158,9 +165,21 @@ const PROJECTION_FOLLOW_SET: &[&str] = &[
/// only when `b` has no alias — `on` is not a base-table name a
/// learner would type as an alias.
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
"where", "group", "order", "having", "limit",
"union", "intersect", "except",
"inner", "left", "right", "full", "cross", "join", "on",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
"inner",
"left",
"right",
"full",
"cross",
"join",
"on",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
// SELECT row source must not read it as table `t`'s bare alias.
@@ -172,15 +191,9 @@ fn peek_next_ident_lower(source: &str, pos: usize) -> Option<String> {
consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
}
fn projection_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
}
}
fn table_source_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: true,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
PROJECTION_BARE_ALIAS_IDENT,
];
static PROJECTION_AS_ALIAS_NODES: &[Node] =
&[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
Node::Lookahead(projection_bare_alias_factory),
];
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
static PROJECTION_ALIAS_OPTIONAL: Node =
Node::Optional(&PROJECTION_ALIAS_CHOICE);
static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
Node::Lookahead(table_source_bare_alias_factory),
];
static TABLE_SOURCE_ALIAS_CHOICE: Node =
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
// =================================================================
// Projection item
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_STAR_NODES: &[Node] = &[
QUALIFIED_STAR_QUALIFIER,
Node::Punct('.'),
Node::Punct('*'),
];
static QUALIFIED_STAR_NODES: &[Node] =
&[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[
@@ -310,11 +309,7 @@ static PROJECTION_EXPR_ITEM: Node = Node::Seq(PROJECTION_EXPR_ITEM_NODES);
/// ambiguity between `t.*` and `sql_expr` (which can match a
/// bare `t`), since the walker's `Choice` doesn't backtrack on
/// a committed match.
fn projection_item_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
let p = skip_whitespace(source, pos);
let bytes = source.as_bytes();
if bytes.get(p) == Some(&b'*') {
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
Node::Word(Word::keyword("all")),
];
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
static DISTINCT_OR_ALL_OPTIONAL: Node =
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
// =================================================================
// Table source (FROM / JOIN target)
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static TABLE_SOURCE_NODES: &[Node] = &[
@@ -395,8 +389,7 @@ static TABLE_SOURCE: Node = Node::Seq(TABLE_SOURCE_NODES);
const JOIN_WORD: Node = Node::Word(Word::keyword("join"));
const ON_WORD: Node = Node::Word(Word::keyword("on"));
static OUTER_OPTIONAL: Node =
Node::Optional(&Node::Word(Word::keyword("outer")));
static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
// `INNER JOIN` and bare `JOIN` are split into two Choice
// branches so each branch has a distinct leading keyword
@@ -585,8 +578,7 @@ static SET_OP_CHOICES: &[Node] = &[
];
static SET_OP: Node = Node::Choice(SET_OP_CHOICES);
static SET_OP_TAIL_NODES: &[Node] =
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
static PLAIN_COMPOUND_NODES: &[Node] = &[
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
Node::Subgrammar(&WITH_CLAUSE),
Node::Subgrammar(&PLAIN_COMPOUND),
];
static WITH_PREFIXED_COMPOUND: Node =
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static COMPOUND_CHOICES: &[Node] = &[
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static CTE_COLUMN_LIST_NODES: &[Node] = &[
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
RPAREN,
];
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
static CTE_COLUMN_LIST_OPTIONAL: Node =
Node::Optional(&CTE_COLUMN_LIST_SEQ);
static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
// CTE body recursion pushes a fresh lexical scope frame (ADR-
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
// the top-level statement's own COMPOUND embedding does not
// (it shares the implicit bottom frame).
static CTE_BODY_NODES: &[Node] = &[
LPAREN,
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
RPAREN,
];
static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
static CTE_DEF_NODES: &[Node] = &[
@@ -807,9 +793,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
NodeWalkResult::Matched { end, .. } => {
input[end..].trim().is_empty()
}
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
@@ -819,10 +803,7 @@ mod tests {
}
fn good(input: &str) {
assert!(
walks(input),
"{input:?} should be a valid SELECT statement"
);
assert!(walks(input), "{input:?} should be a valid SELECT statement");
}
fn bad(input: &str) {
@@ -1051,16 +1032,12 @@ mod tests {
#[test]
fn set_op_chain() {
good(
"select a from t union select b from u intersect select c from v",
);
good("select a from t union select b from u intersect select c from v");
}
#[test]
fn set_op_with_outer_order_by_and_limit() {
good(
"select a from t union select b from u order by a limit 10",
);
good("select a from t union select b from u order by a limit 10");
}
// ----- ORDER BY / LIMIT / OFFSET -----
@@ -1126,16 +1103,12 @@ mod tests {
#[test]
fn recursive_cte() {
good(
"with recursive r as (select 1 union all select 2) select * from r",
);
good("with recursive r as (select 1 union all select 2) select * from r");
}
#[test]
fn multiple_ctes() {
good(
"with a as (select 1), b as (select 2) select * from a union select * from b",
);
good("with a as (select 1), b as (select 2) select * from a union select * from b");
}
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
@@ -1147,9 +1120,7 @@ mod tests {
#[test]
fn nested_cte_body_with_union() {
good(
"with x as (select 1 union select 2) select * from x",
);
good("with x as (select 1 union select 2) select * from x");
}
// ----- case insensitivity / spacing -----
@@ -1363,9 +1334,7 @@ mod tests {
#[test]
fn in_subquery_in_where_clause() {
good("select * from t where id in (select user_id from orders)");
good(
"select * from customers where id not in (select customer_id from blocklist)",
);
good("select * from customers where id not in (select customer_id from blocklist)");
}
#[test]
@@ -1378,9 +1347,7 @@ mod tests {
#[test]
fn nested_subqueries() {
good(
"select * from t where x in (select y from u where y in (select z from v))",
);
good("select * from t where x in (select y from u where y in (select z from v))");
}
#[test]
@@ -1393,8 +1360,6 @@ mod tests {
#[test]
fn cte_body_references_qualified_columns() {
good(
"with x as (select t.name, t.age from t) select x.name from x",
);
good("with x as (select t.name, t.age from t) select x.name from x");
}
}
+12 -2
View File
@@ -119,7 +119,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_UPDATE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -130,7 +137,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete UPDATE tail"
);
}
#[test]
+3 -3
View File
@@ -21,9 +21,9 @@ pub mod walker;
pub use action::ReferentialAction;
pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
ShowListKind, SqlForeignKey,
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
RowFilter, ShowListKind, SqlForeignKey,
};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+18 -25
View File
@@ -55,10 +55,9 @@ pub enum ParseError {
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!(
"parse.error_wrapper",
detail = message,
)),
Self::Invalid { message, .. } => {
f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
}
Self::Empty => f.write_str(&crate::t!("parse.empty")),
}
}
@@ -125,10 +124,7 @@ pub fn parse_command_with_schema(
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode(
input: &str,
mode: Mode,
) -> Result<Command, ParseError> {
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
parse_command_inner(input, None, mode)
}
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
.collect();
let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else(
|| (start, None),
|(s, e)| (s, Some(&source[s..e])),
);
let (position, found_word) = consume_ident(source, start)
.map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
let message = found_word.map_or_else(
|| format!("expected one of {joined}"),
|w| format!("expected one of {joined}, found `{w}`"),
@@ -1034,19 +1028,22 @@ mod tests {
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"
),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"
),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e =
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
@@ -1073,7 +1070,9 @@ mod tests {
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
ok(
"add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"
),
rel(
Some("cust_orders"),
("Customers", "Id"),
@@ -1300,10 +1299,7 @@ mod tests {
#[test]
fn advanced_ambiguous_update_routes_to_sql() {
assert!(matches!(
parse_command_in_mode(
"update Orders set total = 0 where id = 1",
Mode::Advanced,
),
parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
Ok(Command::SqlUpdate { .. })
));
}
@@ -1399,10 +1395,7 @@ mod tests {
// in advanced mode)" pointer is added at the hint layer
// (input_render), not in the parsed command/error here.
assert!(matches!(
parse_command_in_mode(
"delete from Orders where id = 1 returning *",
Mode::Simple,
),
parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
Err(ParseError::Invalid { .. })
));
}
+1 -2
View File
@@ -9,8 +9,7 @@ use rand::RngExt;
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
/// because they are easily confused in print.
const ALPHABET: &[u8; 58] =
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const DEFAULT_LEN: usize = 10;
+4 -26
View File
@@ -43,29 +43,9 @@
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
"abs",
"avg",
"coalesce",
"count",
"date",
"datetime",
"hex",
"ifnull",
"instr",
"length",
"lower",
"max",
"min",
"nullif",
"random",
"replace",
"round",
"strftime",
"substr",
"sum",
"trim",
"typeof",
"upper",
"abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
"lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
"trim", "typeof", "upper",
];
/// Whether `partial` is a case-insensitive prefix of at least one
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
#[must_use]
pub fn is_known_function_prefix(partial: &str) -> bool {
let lowered = partial.to_lowercase();
KNOWN_SQL_FUNCTIONS
.iter()
.any(|f| f.starts_with(&lowered))
KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
}
#[cfg(test)]
+2 -9
View File
@@ -59,11 +59,7 @@ impl Type {
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
@@ -107,10 +103,7 @@ impl Type {
/// match against a numeric column (ADR-0027, Amendment 1).
#[must_use]
pub const fn is_numeric(self) -> bool {
matches!(
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
}
/// The user-facing type that an FK column should use to
+37 -18
View File
@@ -129,13 +129,14 @@ impl Value {
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
match self {
Self::Number(n) => n
.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
}),
Self::Number(n) => {
n.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
})
}
other => Err(ValueError::TypeMismatch {
column: column.to_string(),
expected_human: format!("a whole number for `{ty}`"),
@@ -241,9 +242,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> {
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
let bytes = s.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return Err(format!(
"`{s}` is not a date in `YYYY-MM-DD` form"
));
return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
}
let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?;
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
@@ -272,7 +271,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
validate_date(date_part)?;
let bytes = s.as_bytes();
if bytes[10] != b'T' {
return Err(format!("`{s}`: missing `T` separator between date and time"));
return Err(format!(
"`{s}`: missing `T` separator between date and time"
));
}
if bytes[13] != b':' || bytes[16] != b':' {
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
@@ -326,8 +327,14 @@ mod tests {
#[test]
fn integer_for_int_column() {
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
assert_eq!(
n("42").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(42)
);
assert_eq!(
n("-7").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(-7)
);
}
#[test]
@@ -355,7 +362,9 @@ mod tests {
#[test]
fn shortid_validation_runs_on_text_for_shortid_column() {
let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err();
let err = t("toolong_xyz_more")
.bind_for_column("c", Type::ShortId)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
// Well-formed shortid binds fine.
@@ -367,8 +376,14 @@ mod tests {
#[test]
fn bool_for_bool_column_maps_to_zero_or_one() {
assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1));
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
assert_eq!(
Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(1)
);
assert_eq!(
Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(0)
);
}
#[test]
@@ -377,13 +392,17 @@ mod tests {
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
Bound::Text("2025-01-15".to_string())
);
let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025/01/15")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
}
#[test]
fn date_range_check() {
let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025-13-01")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
}
+119 -179
View File
@@ -28,12 +28,10 @@ use crate::completion::TableColumn;
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
///
@@ -77,10 +75,7 @@ static DYNAMIC_CACHE: LazyLock<Mutex<HashMap<DynamicKey, &'static Node>>> =
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
/// reusing a previously-leaked Node when the factory's inputs
/// match a cached entry.
fn resolve_dynamic(
factory: fn(&WalkContext) -> Node,
ctx: &WalkContext,
) -> &'static Node {
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
let key = DynamicKey {
factory: factory as usize,
current_table_columns: ctx.current_table_columns.clone(),
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
expected: Vec<Expectation>,
},
/// Committed and hit a hard mismatch or validator failure.
Failed {
position: usize,
kind: FailureKind,
},
Failed { position: usize, kind: FailureKind },
}
const fn matched(end: usize) -> NodeWalkResult {
@@ -218,9 +210,7 @@ fn walk_node_inner(
kind: FailureKind::Mismatch { expected: vec![] },
}
}
Node::Subgrammar(inner) => {
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
}
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
Node::ScopedSubgrammar(inner) => {
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
}
@@ -247,8 +237,7 @@ fn walk_node_inner(
// DynamicSubgrammar wrapper that delegates to the
// memoized `column_value_list`), so the per-walk
// leak is a few bytes, not a whole typed tree.
let resolved: &'static Node =
Box::leak(Box::new(factory(ctx, source, pos)));
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
walk_node(source, pos, resolved, ctx, path, per_byte)
}
Node::SetColumn(col) => {
@@ -262,7 +251,10 @@ fn walk_node_inner(
let col: &crate::completion::TableColumn = col;
ctx.current_column = Some(col.clone());
ctx.pending_value_column = Some(col.name.clone());
NodeWalkResult::Matched { end: pos, skipped: Vec::new() }
NodeWalkResult::Matched {
end: pos,
skipped: Vec::new(),
}
}
Node::TypedValueSlot {
ty,
@@ -342,7 +334,10 @@ fn walk_word(
// Amendment 4). Plain keywords leave it `None`.
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} else {
NodeWalkResult::NoMatch {
position,
@@ -477,9 +472,7 @@ fn walk_ident(
// ScopedSubgrammar (which is structurally guaranteed to be
// the CTE body — no intervening scoped subgrammar in CTE
// syntax) runs the harvest at body-frame exit.
if writes_cte_name
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame
.cte_bindings
.push(crate::dsl::walker::context::CteBinding {
@@ -487,13 +480,12 @@ fn walk_ident(
columns: Vec::new(),
});
let placeholder_index = frame.cte_bindings.len() - 1;
ctx.pending_cte_harvest =
Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
}
// ADR-0032 §10.3: the optional `(c1, c2, …)` rename list
// between the cte name and `AS`. Each `cte_column` ident
@@ -507,9 +499,7 @@ fn walk_ident(
}
// ADR-0032 §10.4: projection-list alias accumulator for
// ORDER BY completion candidates.
if writes_projection_alias
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame.projection_aliases.push(text.clone());
}
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
@@ -529,9 +519,7 @@ fn walk_ident(
.map(|c| c.name.clone())
.or_else(|| Some(text.clone()));
}
if writes_user_listed_column
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
{
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
// Form A: `insert into <T> (col1, col2, …)`. Append the
// matched column name to user_listed_columns so the
// inner `values (…)` slot list mirrors the user's
@@ -564,7 +552,10 @@ fn walk_ident(
// (issue #8 / ADR-0022 Amendment 4).
class: highlight_override.unwrap_or(HighlightClass::Identifier),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_string_lit(
@@ -648,7 +639,10 @@ fn walk_literal(
end,
class,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_number_lit(
@@ -683,7 +677,10 @@ fn walk_number_lit(
end,
class: HighlightClass::Number,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_flag(
@@ -717,7 +714,10 @@ fn walk_flag(
end,
class: HighlightClass::Flag,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
#[allow(clippy::too_many_arguments)]
@@ -784,7 +784,10 @@ fn walk_repeated(
count += 1;
last_item_skipped = skipped;
}
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
NodeWalkResult::NoMatch {
expected,
position: inner_pos,
} => {
// Mid-typing-the-next-item recovery: if the
// separator just consumed and the inner failed
// at EOF, the user is partway through typing the
@@ -860,7 +863,10 @@ fn walk_bare_path(
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_choice(
@@ -1031,7 +1037,10 @@ fn walk_optional(
skipped: expected,
}
}
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
NodeWalkResult::Incomplete {
position: p,
expected,
} if !inner_committed => {
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
@@ -1156,9 +1165,7 @@ fn walk_scoped_subgrammar(
// walks that NoMatch / Incomplete / Fail leave the placeholder
// empty (the outer-frame state is also discarded in the
// speculative path, so this is correct).
if let (Some(req), NodeWalkResult::Matched { end, .. }) =
(pending_cte, &result)
{
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
run_cte_harvest(ctx, path, source, pos, *end, &req);
}
@@ -1240,9 +1247,8 @@ fn run_cte_harvest(
select_idx = Some(i + 1); // start of projection list
}
MatchedKind::Word(
"from" | "where" | "group" | "having" | "order"
| "limit" | "offset" | "union" | "intersect"
| "except",
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
| "intersect" | "except",
) if select_idx.is_some() => {
end_idx = i;
break;
@@ -1281,12 +1287,7 @@ fn run_cte_harvest(
// Classify each projection item per ADR-0032 §10.3.
let mut derived: Vec<CteColumn> = Vec::new();
for slice in item_slices {
classify_projection_item(
slice,
body_frame,
&ctx.from_scope_stack,
&mut derived,
);
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
}
// Apply (c1, c2, …) positional rename if provided. Types
@@ -1339,8 +1340,7 @@ fn run_cte_harvest(
let stack_len = ctx.from_scope_stack.len();
if stack_len >= 2
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
&& let Some(placeholder) =
outer.cte_bindings.get_mut(req.placeholder_index)
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
{
placeholder.columns = derived;
}
@@ -1368,9 +1368,7 @@ fn classify_projection_item(
// empty because it wasn't a base-table lookup), resolve
// through to the in-scope CteBinding so nested CTEs project
// correctly.
if expr_slice.len() == 1
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
{
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
for binding in &body_frame.from_scope {
for col in expand_binding(binding, scope_stack) {
out.push(col);
@@ -1383,7 +1381,10 @@ fn classify_projection_item(
if expr_slice.len() == 3
&& matches!(
expr_slice[0].kind,
MatchedKind::Ident { role: "qualified_star_qualifier", .. }
MatchedKind::Ident {
role: "qualified_star_qualifier",
..
}
)
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
@@ -1413,11 +1414,7 @@ fn classify_projection_item(
)
{
let col_text = &expr_slice[0].text;
let resolved_type = resolve_bare_column_type_in_frame(
body_frame,
scope_stack,
col_text,
);
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1447,12 +1444,7 @@ fn classify_projection_item(
{
let qual = &expr_slice[0].text;
let col_text = &expr_slice[2].text;
let resolved_type = resolve_qualified_column_type(
body_frame,
scope_stack,
qual,
col_text,
);
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1493,16 +1485,8 @@ fn strip_trailing_alias<'a>(
}
) {
// Optional preceding `AS` keyword.
if slice.len() >= 2
&& matches!(
slice[slice.len() - 2].kind,
MatchedKind::Word("as")
)
{
return (
&slice[..slice.len() - 2],
Some(last.text.clone()),
);
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
return (&slice[..slice.len() - 2], Some(last.text.clone()));
}
return (&slice[..slice.len() - 1], Some(last.text.clone()));
}
@@ -1613,8 +1597,8 @@ fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
#[cfg(test)]
mod tests {
use super::{
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult,
resolve_dynamic, walk_node,
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
walk_node,
};
use crate::dsl::grammar::{Node, Word};
use crate::dsl::walker::context::WalkContext;
@@ -1629,18 +1613,14 @@ mod tests {
Node::Subgrammar(&NESTED),
Node::Punct(')'),
];
static NESTED_CHOICES: &[Node] = &[
Node::Seq(NESTED_GROUP),
Node::Word(Word::keyword("x")),
];
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
static NESTED: Node = Node::Choice(NESTED_CHOICES);
fn walk_nested(input: &str) -> NodeWalkResult {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1726,14 +1706,8 @@ mod tests {
fn resolve_dynamic_cache_is_populated() {
let ctx = WalkContext::new();
let _ = resolve_dynamic(const_factory, &ctx);
let populated = !DYNAMIC_CACHE
.lock()
.expect("cache lock")
.is_empty();
assert!(
populated,
"resolve_dynamic should populate the memo cache",
);
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
assert!(populated, "resolve_dynamic should populate the memo cache",);
}
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
@@ -1758,14 +1732,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let baseline_frames = ctx.from_scope_stack.len();
let result = walk_node(
input,
0,
&SCOPED_NESTED,
&mut ctx,
&mut path,
&mut per_byte,
);
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1801,9 +1768,9 @@ mod tests {
kind: FailureKind::Validation(err),
..
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
other => panic!(
"expected expression_too_deep on pathological scoped nesting, got {other:?}",
),
other => {
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
}
}
}
@@ -1822,9 +1789,7 @@ mod tests {
/// Walk a top-level SQL SELECT and return the bottom frame's
/// `from_scope` after the walk completes. Used to verify that
/// `writes_table` / `writes_table_alias` populate bindings.
fn from_scope_after_walk(
input: &str,
) -> Vec<crate::dsl::walker::context::TableBinding> {
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1871,9 +1836,7 @@ mod tests {
#[test]
fn join_pushes_a_second_binding() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y",
);
let bindings = from_scope_after_walk("select * from a join b on x = y");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1881,9 +1844,7 @@ mod tests {
#[test]
fn join_with_aliases() {
let bindings = from_scope_after_walk(
"select * from a as x join b as y on x.id = y.id",
);
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[0].alias, Some("x".to_string()));
@@ -1893,9 +1854,8 @@ mod tests {
#[test]
fn three_way_join_pushes_three_bindings() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y left join c on y = z",
);
let bindings =
from_scope_after_walk("select * from a join b on x = y left join c on y = z");
assert_eq!(bindings.len(), 3);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1908,9 +1868,8 @@ mod tests {
// binding into the inner scope frame; on exit, the frame
// pops and the inner binding is gone. The outer scope's
// from_scope still contains only `outer_t`.
let bindings = from_scope_after_walk(
"select * from outer_t where id in (select id from inner_t)",
);
let bindings =
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "outer_t");
}
@@ -1921,9 +1880,8 @@ mod tests {
// body's scope frame; on body-frame exit, the inner
// binding goes away. The outer scope contains only
// the CTE-name reference `cte_x`.
let bindings = from_scope_after_walk(
"with cte_x as (select * from base_table) select * from cte_x",
);
let bindings =
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "cte_x");
}
@@ -1940,10 +1898,7 @@ mod tests {
/// `cte_bindings` and `projection_aliases` after the walk.
fn frame_state_after_walk(
input: &str,
) -> (
Vec<crate::dsl::walker::context::CteBinding>,
Vec<String>,
) {
) -> (Vec<crate::dsl::walker::context::CteBinding>, Vec<String>) {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1968,9 +1923,7 @@ mod tests {
#[test]
fn cte_name_pushes_placeholder_binding() {
let (ctes, _) = frame_state_after_walk(
"with cte_x as (select 1) select * from cte_x",
);
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].name, "cte_x");
// §10.3 stage-2 harvest produces one CteColumn per
@@ -1984,9 +1937,8 @@ mod tests {
#[test]
fn multiple_ctes_push_in_order() {
let (ctes, _) = frame_state_after_walk(
"with a as (select 1), b as (select 2) select * from b",
);
let (ctes, _) =
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
assert_eq!(ctes.len(), 2);
assert_eq!(ctes[0].name, "a");
assert_eq!(ctes[1].name, "b");
@@ -2006,25 +1958,20 @@ mod tests {
#[test]
fn projection_aliases_captured_via_as_form() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b as beta from t",
);
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_captured_via_bare_form() {
let (_, aliases) = frame_state_after_walk(
"select a alpha, b beta from t",
);
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_mixed_forms() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b beta, c, d as delta from t",
);
let (_, aliases) =
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
assert_eq!(
aliases,
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
@@ -2033,8 +1980,7 @@ mod tests {
#[test]
fn projection_aliases_empty_when_no_aliases() {
let (_, aliases) =
frame_state_after_walk("select a, b from t");
let (_, aliases) = frame_state_after_walk("select a, b from t");
assert!(aliases.is_empty());
}
@@ -2088,9 +2034,24 @@ mod tests {
s.table_columns.insert(
"users".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
TableColumn {
name: "age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
],
);
s
@@ -2108,10 +2069,7 @@ mod tests {
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name"));
assert_eq!(
ctes[0].columns[1].type_,
@@ -2161,10 +2119,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 1);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
}
#[test]
@@ -2259,15 +2214,9 @@ mod tests {
.expect("outer_cte binding");
assert_eq!(outer.columns.len(), 2);
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
assert_eq!(
outer.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(outer.columns[1].name.as_deref(), Some("name"));
assert_eq!(
outer.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2287,15 +2236,9 @@ mod tests {
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
assert_eq!(b.columns.len(), 2);
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
assert_eq!(
b.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(b.columns[1].name.as_deref(), Some("name"));
assert_eq!(
b.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2310,10 +2253,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b"));
assert_eq!(
ctes[0].columns[1].type_,
+41 -28
View File
@@ -24,8 +24,8 @@
use crate::dsl::grammar::HighlightClass;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, WalkBound};
@@ -47,16 +47,11 @@ pub fn highlight_runs(source: &str) -> Vec<ByteClass> {
/// token, producing the keyword classes the renderer needs to
/// colour `select` / `from` / `where` / `union` / `case` / etc.
#[must_use]
pub fn highlight_runs_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Vec<ByteClass> {
pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
let mut ctx = WalkContext::new();
ctx.mode = mode;
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
let mut classes: Vec<ByteClass> = result
.map(|r| r.per_byte_class)
.unwrap_or_default();
let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
let scan_start = classes.last().map_or(0, |c| c.end);
scan_remainder(source, scan_start, &mut classes);
@@ -133,9 +128,7 @@ fn scan_remainder(source: &str, start: usize, classes: &mut Vec<ByteClass>) {
.get(pos + 1)
.copied()
.is_some_and(|c| c.is_ascii_digit()));
if looks_like_number
&& let Some((s, e)) = consume_number_literal(source, pos)
{
if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
classes.push(ByteClass {
start: s,
end: e,
@@ -222,8 +215,14 @@ mod tests {
"no Error highlight on a valid m:n line: {runs:?}"
);
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
assert!(
kinds.contains(&HighlightClass::Keyword),
"keywords highlighted: {runs:?}"
);
assert!(
kinds.contains(&HighlightClass::Identifier),
"table names highlighted: {runs:?}"
);
}
#[test]
@@ -276,10 +275,7 @@ mod tests {
#[test]
fn flag_classified_via_fallback() {
// Walker doesn't engage for a bare `--all-rows`.
assert_eq!(
run("--all-rows"),
vec![(0, 10, HighlightClass::Flag)],
);
assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
}
#[test]
@@ -445,15 +441,13 @@ mod tests {
// dispatcher, so only the entry word would highlight).
let runs = run_advanced("select * from t");
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (0, 6)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
"expected `select` keyword span 0..6; got {runs:?}",
);
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (9, 13)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
"expected `from` keyword span 9..13; got {runs:?}",
);
}
@@ -514,18 +508,37 @@ mod tests {
let insert = keywords_of(
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a",
);
for kw in ["insert", "into", "values", "on", "conflict", "do", "update", "set", "returning"] {
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}");
for kw in [
"insert",
"into",
"values",
"on",
"conflict",
"do",
"update",
"set",
"returning",
] {
assert!(
insert.contains(&kw),
"INSERT/UPSERT: missing `{kw}`; got {insert:?}"
);
}
let update = keywords_of("update t set a = 1 where id = 2 returning a");
for kw in ["update", "set", "where", "returning"] {
assert!(update.contains(&kw), "UPDATE: missing `{kw}`; got {update:?}");
assert!(
update.contains(&kw),
"UPDATE: missing `{kw}`; got {update:?}"
);
}
let delete = keywords_of("delete from t where id = 1 returning *");
for kw in ["delete", "from", "where", "returning"] {
assert!(delete.contains(&kw), "DELETE: missing `{kw}`; got {delete:?}");
assert!(
delete.contains(&kw),
"DELETE: missing `{kw}`; got {delete:?}"
);
}
}
}
+1 -3
View File
@@ -110,9 +110,7 @@ pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usiz
return None;
}
let mut i = start;
let leading_minus = bytes[i] == b'-'
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit();
let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
if leading_minus {
i += 1;
}
+430 -559
View File
File diff suppressed because it is too large Load Diff
+110 -38
View File
@@ -14,12 +14,12 @@
//! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
@@ -79,7 +79,12 @@ pub fn echo_for_query(
name,
filter,
limit,
} => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]),
} => Some(vec![render_show_data(
name,
filter.as_ref(),
*limit,
primary_key,
)]),
_ => None,
}
}
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
column,
kind,
} => match kind {
ConstraintKind::NotNull => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"))
}
ConstraintKind::Default => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"))
}
ConstraintKind::NotNull => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
)),
ConstraintKind::Default => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
)),
// A column-level UNIQUE / CHECK is anonymous in our model —
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
table,
assignments,
filter: RowFilter::AllRows,
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))),
} => Some(format!(
"UPDATE {table} SET {}",
render_assignments(assignments)
)),
Command::Delete {
table,
filter: RowFilter::AllRows,
@@ -199,7 +207,13 @@ fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String
// The same column-constraint suffix `add column` emits (ADR-0029):
// simple-mode `create table` can carry `default` / `check` too, so
// the echo must render them or it is not equivalent (§1 contract).
append_constraints(&mut s, c.not_null, c.unique, c.default.as_ref(), c.check.as_ref());
append_constraints(
&mut s,
c.not_null,
c.unique,
c.default.as_ref(),
c.check.as_ref(),
);
s
})
.collect();
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
let mut parts: Vec<String> =
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
let mut parts: Vec<String> = columns
.iter()
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
.collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!(
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
) -> Vec<String> {
let mut lines: Vec<String> = new_columns
.iter()
.map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword()))
.map(|(col, ty)| {
format!(
"ALTER TABLE {child_table} ADD COLUMN {col} {}",
ty.keyword()
)
})
.collect();
lines.push(render_add_relationship(
name,
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
negated,
} => {
let not = if *negated { "NOT " } else { "" };
format!("{} {not}LIKE {}", operand_to_sql(target), operand_to_sql(pattern))
format!(
"{} {not}LIKE {}",
operand_to_sql(target),
operand_to_sql(pattern)
)
}
Predicate::Between {
target,
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
} => {
let not = if *negated { "NOT " } else { "" };
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
format!("{} {not}IN ({})", operand_to_sql(target), rendered.join(", "))
format!(
"{} {not}IN ({})",
operand_to_sql(target),
rendered.join(", ")
)
}
Predicate::IsNull { target, negated } => {
let not = if *negated { "NOT " } else { "" };
@@ -562,7 +591,10 @@ mod tests {
fn create_table_compound_pk_renders_table_level() {
let cmd = create_table(
"T",
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
],
&["a", "b"],
);
assert_eq!(
@@ -594,7 +626,11 @@ mod tests {
default: Some(Value::Text("A".to_string())),
..ColumnSpec::new("grade", Type::Text)
};
let cmd = create_table("T", vec![ColumnSpec::new("id", Type::Serial), age, grade], &["id"]);
let cmd = create_table(
"T",
vec![ColumnSpec::new("id", Type::Serial), age, grade],
&["id"],
);
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(
sql,
@@ -625,11 +661,11 @@ mod tests {
check: None,
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'");
assert!(matches!(
reparse(&sql),
Ok(Command::SqlAlterTable { .. })
));
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
#[test]
@@ -657,7 +693,10 @@ mod tests {
})),
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)");
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
@@ -1031,7 +1070,10 @@ mod tests {
let lines = render_drop_column_cascade(
"Orders",
"CustId",
&["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()],
&[
"Orders_CustId_idx".to_string(),
"Orders_CustId_Day_idx".to_string(),
],
);
assert_eq!(
lines.as_slice(),
@@ -1043,9 +1085,18 @@ mod tests {
);
// Each line is itself runnable advanced-mode SQL (the §1 contract
// holds per line for category 2).
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[2]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1054,7 +1105,10 @@ mod tests {
// plain `DROP COLUMN` — still semantically equivalent.
let lines = render_drop_column_cascade("T", "c", &[]);
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1078,8 +1132,14 @@ mod tests {
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
]
);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1116,8 +1176,16 @@ mod tests {
],
&["Students_id".to_string(), "Courses_id".to_string()],
&[
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
(
vec!["Students_id".to_string()],
"Students".to_string(),
vec!["id".to_string()],
),
(
vec!["Courses_id".to_string()],
"Courses".to_string(),
vec!["id".to_string()],
),
],
);
assert_eq!(
@@ -1172,8 +1240,14 @@ mod tests {
#[test]
fn value_literal_renders_null_uppercase_and_quotes_text() {
assert_eq!(value_to_sql_literal(&Value::Null), "NULL");
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'");
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14");
assert_eq!(
value_to_sql_literal(&Value::Text("O'Hara".to_string())),
"'O''Hara'"
);
assert_eq!(
value_to_sql_literal(&Value::Number("3.14".to_string())),
"3.14"
);
assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
}
@@ -1258,9 +1332,7 @@ mod tests {
"Command::App({app:?}) is Bucket C — no echo"
);
// Also confirm echo_for gates the same in advanced mode.
assert!(
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
);
assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
}
}
+10 -5
View File
@@ -8,9 +8,8 @@
use crossterm::event::KeyEvent;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
UpdateResult,
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
};
use crate::dsl::Command;
@@ -73,10 +72,16 @@ pub enum AppEvent {
},
/// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed.
DslExplainSucceeded { command: Command, plan: QueryPlan },
DslExplainSucceeded {
command: Command,
plan: QueryPlan,
},
/// A `show <kind>` list command (V5) — carries pre-formatted
/// display lines (tables / relationships / indexes).
DslShowListSucceeded { command: Command, lines: Vec<String> },
DslShowListSucceeded {
command: Command,
lines: Vec<String>,
},
/// `show relationship <name>` (ADR-0044) — structured data for the
/// diagram, rendered App-side; `None` when no such relationship.
DslShowRelationshipSucceeded {
+3 -11
View File
@@ -43,17 +43,11 @@ impl Catalog {
}
}
fn flatten(
value: &serde_norway::Value,
prefix: String,
out: &mut HashMap<String, String>,
) {
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
match value {
serde_norway::Value::Mapping(map) => {
for (k, v) in map {
let k_str = k
.as_str()
.expect("catalog keys must be strings");
let k_str = k.as_str().expect("catalog keys must be strings");
let next = if prefix.is_empty() {
k_str.to_string()
} else {
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
/// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
let template = catalog().get(key).unwrap_or_else(|| {
panic!(
"missing catalog key: `{key}` (the validator should have caught this)"
);
panic!("missing catalog key: `{key}` (the validator should have caught this)");
});
substitute(template, args, key)
}
+24 -29
View File
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("diagnostic.alias_used_as_column", &["name"]),
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
("diagnostic.auto_column_overridden", &["column", "type"]),
("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]),
("diagnostic.cte_arity_mismatch", &["cte", "declared", "actual"]),
(
"diagnostic.compound_arity_mismatch",
&["op", "left_n", "right_n"],
),
(
"diagnostic.cte_arity_mismatch",
&["cte", "declared", "actual"],
),
("diagnostic.duplicate_cte", &["name"]),
("diagnostic.eq_null", &[]),
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
),
("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
(
"diagnostic.projection_alias_misplaced",
&["alias", "clause"],
),
("diagnostic.table_used_as_column", &["name"]),
("diagnostic.type_mismatch", &["column", "type"]),
("diagnostic.unknown_column", &["name", "table"]),
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"error.type_mismatch.change_column.headline",
&["table", "column", "src_type", "target_type"],
),
(
"error.type_mismatch.change_column.hint",
&["target_type"],
),
("error.type_mismatch.change_column.hint", &["target_type"]),
(
"error.type_mismatch.insert.headline",
&["value", "expected_type"],
@@ -219,10 +225,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.explain", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]),
(
"hint.ambient_error_with_usage",
&["message", "usage"],
),
("hint.ambient_error_with_usage", &["message", "usage"]),
("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]),
("hint.block.heading", &[]),
@@ -404,10 +407,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.cmd.explain_sql.what", &[]),
("hint.cmd.explain_sql.example", &[]),
("hint.cmd.explain_sql.concept", &[]),
(
"hint.ambient_invalid_ident",
&["kind", "found"],
),
("hint.ambient_invalid_ident", &["kind", "found"]),
("hint.ambient_typing_name", &[]),
// Issue #4: introduce the advanced-mode CREATE TABLE element
// slot (`create table T (`) so the otherwise-invisible
@@ -415,10 +415,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.create_table_element", &[]),
("hint.seed_count", &[]),
("hint.value_literal_slot", &[]),
(
"hint.ambient_typing_name_then",
&["next"],
),
("hint.ambient_typing_name_then", &["next"]),
// Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]),
@@ -441,7 +438,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.custom.alter_named_unique", &[]),
("parse.custom.bind_type_mismatch", &["found", "expected"]),
("parse.custom.change_column_flags_exclusive", &[]),
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
(
"parse.custom.constraint_redundant_on_pk",
&["column", "constraint"],
),
("parse.custom.create_table_needs_pk", &[]),
("parse.custom.expression_too_deep", &[]),
("parse.custom.insert_form_a_missing_values", &["columns"]),
@@ -576,10 +576,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
),
("select.internal_table", &["table"]),
(
"cli.invalid_value",
&["flag", "value", "expected"],
),
("cli.invalid_value", &["flag", "value", "expected"]),
("cli.missing_value", &["flag"]),
("cli.multiple_paths", &["first", "second"]),
("cli.resume_with_path", &[]),
@@ -867,8 +864,7 @@ mod tests {
}
}
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
for key in cat.keys() {
if key.starts_with("_test.") {
continue;
@@ -890,9 +886,8 @@ mod tests {
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
/// duplicated here so the catalog validator is self-contained
/// (no dependency on the integration-test binary).
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
];
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
&["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
/// Detect a `{name:...}` format-specifier placeholder.
/// Doubled braces `{{` / `}}` are escapes — must skip them.
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod keys;
pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
pub use format::{Catalog, catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
+79 -76
View File
@@ -201,11 +201,7 @@ impl TranslateContext {
/// Combine schema-resolved facts with operation and
/// verbosity to build the full translator input.
#[must_use]
pub fn from_facts(
operation: Operation,
verbosity: Verbosity,
facts: FailureContext,
) -> Self {
pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self {
Self {
operation: Some(operation),
table: facts.table,
@@ -234,15 +230,15 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
// refusal sites). Catalog entries exist for the typed
// invalid-value cases but the migration sweep
// (ADR-0019 §9) is what wires them. For now, passthrough.
DbError::Unsupported(message) | DbError::InvalidValue(message) => {
passthrough(message)
}
DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message),
DbError::PersistenceFatal { message, .. }
| DbError::RebuildRowFailed { detail: message, .. }
| DbError::RebuildRowFailed {
detail: message, ..
}
| DbError::Io(message) => passthrough(message),
DbError::WorkerGone => passthrough(
"the database worker is no longer available — the application must restart",
),
DbError::WorkerGone => {
passthrough("the database worker is no longer available — the application must restart")
}
};
// Attach the row pinpoint when the runtime resolved one.
// The translator never builds the table itself — it only
@@ -320,11 +316,7 @@ const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
}
}
fn translate_sqlite(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> FriendlyError {
fn translate_sqlite(message: &str, kind: SqliteErrorKind, ctx: &TranslateContext) -> FriendlyError {
// `change column ... --dont-convert` lets the engine
// accept or refuse each cell. Whatever the engine returns
// (constraint, datatype mismatch, …) means "the new type
@@ -392,8 +384,8 @@ fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError
// ---- UNIQUE -----------------------------------------------------
fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let value = ctx_value(ctx);
match ctx.operation {
Some(Operation::Update) => fe(
@@ -405,11 +397,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.update.hint",
table = table,
column = column
),
t!("error.unique.update.hint", table = table, column = column),
),
),
// Default to the INSERT variant — it's the most common
@@ -425,11 +413,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.insert.hint",
table = table,
column = column
),
t!("error.unique.insert.hint", table = table, column = column),
),
),
}
@@ -542,8 +526,8 @@ fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError {
// ---- NOT NULL --------------------------------------------------
fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
match ctx.operation {
Some(Operation::Update) => fe(
t!(
@@ -576,9 +560,17 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
let column = ctx_column(ctx);
let is_update = matches!(ctx.operation, Some(Operation::Update));
let headline = if is_update {
t!("error.check.update.headline", table = table, column = column)
t!(
"error.check.update.headline",
table = table,
column = column
)
} else {
t!("error.check.insert.headline", table = table, column = column)
t!(
"error.check.insert.headline",
table = table,
column = column
)
};
let hint = ctx.check_rule.as_ref().map_or_else(
|| {
@@ -613,8 +605,7 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
// ---- not_found / already_exists --------------------------------
fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError {
let name = parse_after_colon(message)
.map_or_else(|| ctx_table(ctx), str::to_string);
let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string);
headline_only(t!("error.not_found.table.headline", name = name))
}
@@ -656,17 +647,11 @@ fn translate_already_exists(message: &str, ctx: &TranslateContext) -> FriendlyEr
column = column
));
}
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
// No backticks — engine-style "table T already exists".
if let Some(name) = parse_after_word(message, "table") {
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
if let Some(name) = parse_after_word(message, "relationship") {
return headline_only(t!(
@@ -696,36 +681,25 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
if lower.contains("misuse of aggregate") {
return headline_only(t!("engine.aggregate_misuse", name = "?"));
}
if lower.contains("group by")
|| lower.contains("must appear in")
{
if lower.contains("group by") || lower.contains("must appear in") {
return headline_only(t!("engine.group_by_required"));
}
if (lower.contains("union")
|| lower.contains("intersect")
|| lower.contains("except"))
if (lower.contains("union") || lower.contains("intersect") || lower.contains("except"))
&& lower.contains("result columns")
{
// Last-resort safety net — the pre-flight pass in 2d.1
// catches this in most cases; if the engine surfaces it
// anyway, route it through the engine-neutral key.
return headline_only(t!(
"engine.compound_arity_mismatch",
op = "set operator"
));
return headline_only(t!("engine.compound_arity_mismatch", op = "set operator"));
}
if lower.contains("scalar subquery") || lower.contains("more than one row") {
return headline_only(t!("engine.scalar_subquery_too_many_rows"));
}
if lower.contains("recursive")
&& (lower.contains("cte") || lower.contains("union"))
{
if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) {
return headline_only(t!("engine.recursive_cte_malformed"));
}
let operation = ctx
.operation
.map_or("operation", Operation::keyword);
let operation = ctx.operation.map_or("operation", Operation::keyword);
// F2 (ADR-0035 Amendment 1): when no table is in context, use the
// table-less hint so a contextless `friendly_message()` (replay, undo,
// rebuild, export) never renders a literal `{table}` placeholder.
@@ -789,23 +763,33 @@ fn ctx_table(ctx: &TranslateContext) -> String {
}
fn ctx_column(ctx: &TranslateContext) -> String {
ctx.column.clone().unwrap_or_else(|| "the column".to_string())
ctx.column
.clone()
.unwrap_or_else(|| "the column".to_string())
}
fn ctx_value(ctx: &TranslateContext) -> String {
ctx.value.clone().unwrap_or_else(|| "that value".to_string())
ctx.value
.clone()
.unwrap_or_else(|| "that value".to_string())
}
fn ctx_parent_table(ctx: &TranslateContext) -> String {
ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string())
ctx.parent_table
.clone()
.unwrap_or_else(|| "the referenced table".to_string())
}
fn ctx_parent_column(ctx: &TranslateContext) -> String {
ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string())
ctx.parent_column
.clone()
.unwrap_or_else(|| "the referenced column".to_string())
}
fn ctx_child_table(ctx: &TranslateContext) -> String {
ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string())
ctx.child_table
.clone()
.unwrap_or_else(|| "the referencing table".to_string())
}
/// Extract `T.col` from a message like
@@ -847,11 +831,7 @@ fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
let rest = message[pos..].trim_start();
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
if token.is_empty() {
None
} else {
Some(token)
}
if token.is_empty() { None } else { Some(token) }
}
#[cfg(test)]
@@ -876,15 +856,24 @@ mod tests {
};
let d = TranslateContext::default;
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"),
&d()
),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"),
&d()
),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
error_hint_class(
&sqlite(SqliteErrorKind::AlreadyExists, "already exists"),
&d()
),
Some("already_exists")
);
assert_eq!(
@@ -933,13 +922,19 @@ mod tests {
parent_table: Some("Parent".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
assert_eq!(
error_hint_class(&fk(), &ctx),
Some("foreign_key.child_side")
);
// child_table populated → parent-side.
let ctx = TranslateContext {
child_table: Some("Child".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
assert_eq!(
error_hint_class(&fk(), &ctx),
Some("foreign_key.parent_side")
);
// No enrichment: operation is the tiebreaker.
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
@@ -1049,14 +1044,22 @@ mod tests {
ctx.parent_column = Some("country, code".to_string());
ctx.value = Some("7, 8".to_string());
let f = translate(&err, &ctx);
assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline);
assert!(
f.headline.contains("no parent row"),
"child-side: {}",
f.headline
);
assert!(f.headline.contains("Region"));
assert!(
f.headline.contains("country, code"),
"both parent columns must appear: {}",
f.headline
);
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline);
assert!(
f.headline.contains("`7, 8`"),
"joined value: {}",
f.headline
);
}
#[test]
+139 -143
View File
@@ -25,9 +25,9 @@
use ratatui::style::{Color, Modifier, Style};
use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode};
use crate::mode::Mode;
use crate::dsl::walker;
use crate::dsl::{ParseError, parse_command};
use crate::mode::Mode;
use crate::theme::Theme;
/// A run of text with its byte range in the source and the
@@ -85,7 +85,16 @@ pub fn render_input_runs_in_mode(
mode: Mode,
) -> Vec<StyledRun> {
// Identity feedback view — highlight/overlay the whole input.
render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0)
render_input_runs_feedback(
input,
cursor_byte,
theme,
cache,
mode,
input,
cursor_byte,
0,
)
}
/// [`render_input_runs_in_mode`] with a separate **feedback view** for
@@ -121,12 +130,14 @@ pub fn render_input_runs_feedback(
byte_range: (0, offset),
style: ratatui::style::Style::default().fg(theme.fg),
}];
r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| {
StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}
}));
r.extend(
lex_to_runs_in_mode(view, theme, mode)
.into_iter()
.map(|run| StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}),
);
r
};
if let InputState::DefiniteErrorAt(pos) =
@@ -150,7 +161,11 @@ pub fn render_input_runs_feedback(
walker::Severity::Error => theme.tok_error,
walker::Severity::Warning => theme.warning,
};
overlay_span(&mut runs, (diag.span.0 + offset, diag.span.1 + offset), colour);
overlay_span(
&mut runs,
(diag.span.0 + offset, diag.span.1 + offset),
colour,
);
}
inject_cursor(&mut runs, input, cursor_byte, theme);
runs
@@ -234,9 +249,7 @@ pub fn classify_input_with_schema_in_mode(
))
}
fn classify_parse_result(
result: Result<crate::dsl::Command, ParseError>,
) -> InputState {
fn classify_parse_result(result: Result<crate::dsl::Command, ParseError>) -> InputState {
match result {
Ok(_) => InputState::Valid,
Err(ParseError::Empty) => InputState::Empty,
@@ -372,8 +385,7 @@ pub fn advanced_alternative_note(
// carries a blocking ERROR diagnostic such as a value-count
// mismatch. Incomplete input (still being typed) and empty input are
// excluded so the pointer doesn't flicker mid-keystroke.
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple)
{
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) {
InputState::DefiniteErrorAt(_) => true,
InputState::Valid => {
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
@@ -386,8 +398,7 @@ pub fn advanced_alternative_note(
}
// The validity-verdict-driven gate (ADR-0033 Amendment 5): the
// line must be fully valid (verdict `None`) in advanced mode.
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some()
{
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() {
return None;
}
Some(crate::t!("advanced_mode.also_valid_sql"))
@@ -714,8 +725,7 @@ fn ambient_hint_core_in_mode(
// narrows column candidates to the active table and runs the
// §10.6 look-ahead, so it is the authoritative "what can go
// here" set.
let completion =
crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
// Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
// is non-empty only for a command that *structurally parses*
@@ -834,7 +844,9 @@ fn ambient_hint_core_in_mode(
// keyword set.
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
}
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
Some(
crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default,
)
| None => {}
}
@@ -855,7 +867,8 @@ fn ambient_hint_core_in_mode(
// Invalid identifier: cursor sits in a known-set slot but
// the typed prefix matches nothing in the schema. (Stage
// 8e / the user's #5.)
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
if let Some(inv) =
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
{
let kind = match inv.source {
crate::dsl::grammar::IdentSource::Tables => "table",
@@ -1036,11 +1049,7 @@ pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
/// with `Mode::Advanced` so SQL keywords past the entry word
/// match and get highlighted (ADR-0030 §8).
#[must_use]
pub fn lex_to_runs_in_mode(
input: &str,
theme: &Theme,
mode: Mode,
) -> Vec<StyledRun> {
pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
base_runs(input, theme, mode)
}
@@ -1076,12 +1085,7 @@ fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
runs
}
fn inject_cursor(
runs: &mut Vec<StyledRun>,
input: &str,
cursor_byte: usize,
theme: &Theme,
) {
fn inject_cursor(runs: &mut Vec<StyledRun>, input: &str, cursor_byte: usize, theme: &Theme) {
let cursor_byte = cursor_byte.min(input.len());
// End-of-input cursor: append the empty-range sentinel.
@@ -1164,9 +1168,10 @@ mod tests {
let mut cache = SchemaCache::default();
cache.tables.push("Customers".into());
cache.columns.push("name".into());
cache
.table_columns
.insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]);
cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("name", Type::Text)],
);
let input = ": select name from Customers";
let view = "select name from Customers";
let offset = 2; // ": "
@@ -1362,9 +1367,10 @@ mod tests {
let mut cache = crate::completion::SchemaCache::default();
cache.tables.push("users".to_string());
cache.columns.push("email".to_string());
cache
.table_columns
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]);
cache.table_columns.insert(
"users".to_string(),
vec![TableColumn::new("email", Type::Text)],
);
cache
}
@@ -1392,7 +1398,10 @@ mod tests {
}
// Tab candidates remain available (completion is independent).
let comp = crate::completion::candidates_at_cursor_in_mode(
input, input.len(), &cache, Mode::Simple,
input,
input.len(),
&cache,
Mode::Simple,
)
.expect("completion remains available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
@@ -1424,7 +1433,10 @@ mod tests {
&hint,
Some(AmbientHint::Prose(p)) if p.contains("row count")
);
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
assert!(
!is_count_prose,
"count hint must not show for {input:?}; got {hint:?}"
);
}
}
@@ -1502,14 +1514,12 @@ mod tests {
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string());
let tc = vec![
TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
];
let tc = vec![TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}];
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1551,9 +1561,7 @@ mod tests {
p.contains("No such") && p.contains("Agx"),
"a genuine column typo before FROM must warn at typing time; got: {p:?}",
),
other => panic!(
"`select Agx` must surface a typing-time typo hint; got: {other:?}",
),
other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",),
}
}
@@ -1652,8 +1660,7 @@ mod tests {
// ADR-0022 Amendment 1: advanced-mode ambient assistance
// surfaces SQL completion candidates (here the FROM-slot
// table) instead of the simple-mode "this is SQL" gate.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
match ambient_hint_in_mode(
input,
@@ -1678,10 +1685,7 @@ mod tests {
// `INSERT … (` column list. (The simple-mode DSL value-slot
// prose is a separate surface; this pins the §8 advanced claim.)
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let set_slot = "update Customers set ";
match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) {
@@ -1706,16 +1710,10 @@ mod tests {
fn simple_mode_ambient_does_not_surface_sql_candidates() {
// The simple-mode entry point keeps gating SQL — advanced
// assistance is opt-in via mode, never leaked into simple.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
let hint = ambient_hint_in_mode(
input,
input.len(),
None,
&cache,
crate::mode::Mode::Simple,
);
let hint =
ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple);
let offers_table = matches!(
&hint,
Some(AmbientHint::Candidates { items, .. })
@@ -1733,8 +1731,7 @@ mod tests {
fn f1_mid_typed_table_prefix_shows_completion_not_error() {
// "select * from c" — `c` prefix-matches `Customers`. The
// hint must offer the completion, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from c",
"select * from c".len(),
@@ -1753,8 +1750,7 @@ mod tests {
#[test]
fn f1_genuinely_unknown_table_still_shows_error() {
// "zzz" matches no table prefix — the error must still show.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from zzz",
"select * from zzz".len(),
@@ -1773,8 +1769,7 @@ mod tests {
fn f1_simple_mode_dsl_mid_typed_table_completes() {
// The same shadowing affects DSL commands in simple mode:
// "show data c" must offer Customers, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"show data c",
"show data c".len(),
@@ -1804,7 +1799,12 @@ mod tests {
cache.columns.push("order_col".to_string());
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn { name: "order_col".to_string(), user_type: Type::Int, not_null: false, has_default: false }],
vec![TableColumn {
name: "order_col".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}],
);
let comp = crate::completion::candidates_at_cursor_in_mode(
@@ -1846,9 +1846,7 @@ mod tests {
for c in &columns {
cache.columns.push(c.name.clone());
}
cache
.table_columns
.insert(table.to_string(), columns);
cache.table_columns.insert(table.to_string(), columns);
cache
}
@@ -1860,7 +1858,11 @@ mod tests {
("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
(
"Products",
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)],
&[
("id", Type::Serial),
("name", Type::Text),
("price", Type::Decimal),
],
),
(
"OrderLines",
@@ -1873,13 +1875,19 @@ mod tests {
),
(
"Orders",
&[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)],
&[
("id", Type::Serial),
("customer_id", Type::Int),
("date", Type::Date),
],
),
];
for (t, cols) in tables {
cache.tables.push((*t).to_string());
let tc: Vec<TableColumn> =
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect();
let tc: Vec<TableColumn> = cols
.iter()
.map(|(n, ty)| TableColumn::new(*n, *ty))
.collect();
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1914,17 +1922,11 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("integer"),
"expected int-slot prose, got: {p:?}",
);
assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -1942,7 +1944,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
&[
("id", Type::Serial),
("Name", Type::Text),
("Email", Type::Text),
],
);
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
match ambient_hint(input, input.len(), None, &cache) {
@@ -1966,10 +1972,7 @@ mod tests {
// A valid simple-mode DSL command gets no advanced pointer —
// it isn't an error, and there is nothing to switch modes for.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
let input = "insert into Customers values ('Alice')";
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
assert!(
@@ -2010,10 +2013,7 @@ mod tests {
#[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2029,10 +2029,8 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_shows_per_column_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Birthday", Type::Date)],
);
let cache =
schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]);
let input = "update Customers set Birthday=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2057,9 +2055,7 @@ mod tests {
// hasn't committed), the Optional propagates Incomplete
// and the user sees no error overlay until they submit.
assert_eq!(
classify_input(
"insert into Orders (id, CustId, Total) values (42, 89, 17.59"
),
classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"),
InputState::IncompleteAtEof,
);
assert_eq!(
@@ -2071,18 +2067,12 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("id"), "expected column name `id`, got {p:?}");
assert!(
p.contains("integer"),
"expected int prose, got {p:?}",
);
assert!(p.contains("integer"), "expected int prose, got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2091,10 +2081,7 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
let input = "update Customers set Email=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2131,10 +2118,7 @@ mod tests {
#[test]
fn ambient_hint_at_second_insert_value_mentions_second_column() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2165,14 +2149,20 @@ mod tests {
use crate::dsl::types::Type;
let cases: &[(&[(&str, Type)], &str)] = &[
// string first value (the report's case): first col text.
(&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'"),
(
&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'",
),
// integer first value: first col int.
(&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42"),
(
&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42",
),
// real first value: first col real.
(&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5"),
(
&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5",
),
];
for (cols, input) in cases {
let cache = schema_with_columns("Customers", cols);
@@ -2232,10 +2222,7 @@ mod tests {
// is nothing left to fill. Guards against over-correcting the
// fix into never suggesting the close paren.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("Name", Type::Text), ("Age", Type::Int)],
);
let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]);
let input = "insert into Customers values ('Oli', 52";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2384,10 +2371,7 @@ mod tests {
match ambient_hint("show data Missing", 17, None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("Missing"), "got {p:?}");
assert!(
p.to_lowercase().contains("no such table"),
"got {p:?}",
);
assert!(p.to_lowercase().contains("no such table"), "got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2440,8 +2424,7 @@ mod tests {
use crate::dsl::types::Type;
// Two type-mismatch WARNINGs; the hint names the column
// whose offending literal the cursor sits in.
let cache =
schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let input = "delete from Events where a = 'x' or b = 'y'";
let on_x = input.find("'x'").expect("'x' literal") + 1;
let on_y = input.find("'y'").expect("'y' literal") + 1;
@@ -2460,8 +2443,16 @@ mod tests {
inserted_range: (5, 5),
original_text: String::new(),
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2494,8 +2485,16 @@ mod tests {
// produce — proves the memo's list is being used,
// not a recomputed one.
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2564,10 +2563,7 @@ mod tests {
fn classify_trailing_whitespace_does_not_create_definite_error() {
// Trailing whitespace alone shouldn't promote an
// incomplete-at-EOF state into a definite error.
assert_eq!(
classify_input("create "),
InputState::IncompleteAtEof,
);
assert_eq!(classify_input("create "), InputState::IncompleteAtEof,);
}
#[test]
+3 -4
View File
@@ -60,8 +60,8 @@ pub fn init(path: Option<&Path>) -> Result<PathBuf> {
.with_context(|| format!("create log directory {}", parent.display()))?;
}
let file = open_log_file(&chosen)?;
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG")
.unwrap_or_else(|_| EnvFilter::new("info"));
let filter =
EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
let layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
@@ -95,8 +95,7 @@ fn home_dir() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("HOME") {
return Some(PathBuf::from(p));
}
if let (Some(drive), Some(path)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut combined = PathBuf::from(drive);
combined.push(path);
+1 -1
View File
@@ -1,6 +1,6 @@
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};
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
/// plan vocabulary may grow (ADR-0028 §4).
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
(
"USING AUTOMATIC COVERING INDEX",
OutputStyleClass::AutomaticIndex,
),
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
("USING COVERING INDEX", OutputStyleClass::Efficient),
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
@@ -225,8 +228,7 @@ fn render_plan_subtree(
emitted: &mut HashSet<i64>,
mode: Mode,
) {
let children: Vec<&ExplainRow> =
rows.iter().filter(|r| r.parent == parent).collect();
let children: Vec<&ExplainRow> = rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) {
@@ -235,8 +237,7 @@ fn render_plan_subtree(
let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " };
out.push(plan_node_line(prefix, connector, &row.detail, mode));
let child_prefix =
format!("{prefix}{}", if is_last { " " } else { "" });
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "" });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
}
}
@@ -343,13 +344,8 @@ pub fn render_diagnostic_table(
const fn alignment_for(ty: Option<Type>) -> Alignment {
match ty {
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
Some(Type::Text)
| Some(Type::Bool)
| Some(Type::Date)
| Some(Type::DateTime)
| Some(Type::Blob)
| Some(Type::ShortId)
| None => Alignment::Left,
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
| Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left,
}
}
@@ -406,11 +402,7 @@ fn cell_width(s: &str) -> usize {
/// Render a single bordered table given header cells, body
/// rows, and per-column alignment. Outer frame +
/// header-underline only.
fn render_table(
headers: &[String],
body: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
fn render_table(headers: &[String], body: &[Vec<String>], alignments: &[Alignment]) -> Vec<String> {
debug_assert_eq!(headers.len(), alignments.len());
// Compute column widths: max(header, all body cells).
@@ -792,13 +784,12 @@ fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) -
}
// The vertical bus spans the full range of endpoint rows.
let bounds = child_rows
.iter()
.chain(parent_rows)
.copied()
.fold(None, |acc: Option<(usize, usize)>, r| {
let bounds = child_rows.iter().chain(parent_rows).copied().fold(
None,
|acc: Option<(usize, usize)>, r| {
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
});
},
);
if let Some((top, bot)) = bounds
&& i >= top
&& i <= bot
@@ -1138,7 +1129,10 @@ mod tests {
assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
assert!(out.contains('▶'), "arrowhead:\n{out}");
assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}");
assert!(
out.contains('n') && out.contains('1'),
"cardinality:\n{out}"
);
assert!(
out.contains("on delete cascade · on update no action"),
"actions:\n{out}"
@@ -1237,7 +1231,10 @@ mod tests {
let (r_out, r_in) = blank_rels();
let region = TableDescription {
name: "Region".to_string(),
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
columns: vec![
col("country", Type::Int, true, false),
col("code", Type::Int, true, false),
],
outbound_relationships: r_out,
inbound_relationships: r_in,
indexes: Vec::new(),
@@ -1277,7 +1274,10 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
assert!(
text.contains("(PK) ●"),
"parent endpoint is PK + marked:\n{text}"
);
assert!(
text.contains("(country, region_code) ▶ Region.(country, code)"),
"pairing line:\n{text}",
@@ -1412,11 +1412,7 @@ mod tests {
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
column_types: vec![
Some(Type::Serial),
Some(Type::Text),
Some(Type::Text),
],
column_types: vec![Some(Type::Serial), Some(Type::Text), Some(Type::Text)],
rows: vec![
vec![
Some("1".to_string()),
@@ -1634,7 +1630,10 @@ mod tests {
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker.
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
assert!(
!out.contains("[unique]"),
"plain index unmarked; got:\n{out}"
);
}
#[test]
@@ -1677,7 +1676,10 @@ mod tests {
indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
crate::persistence::TableCheck {
name: None,
expr: "a < b".to_string(),
},
crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()),
expr: "a <> b".to_string(),
@@ -1691,7 +1693,10 @@ mod tests {
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
assert!(
out.contains("check a_lt_b (a <> b)"),
"named check shows its name; got:\n{out}"
);
}
#[test]
@@ -1732,17 +1737,37 @@ mod tests {
let plan = QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
ExplainRow {
id: 1,
parent: 0,
detail: "root".to_string(),
},
ExplainRow {
id: 2,
parent: 1,
detail: "child-a".to_string(),
},
ExplainRow {
id: 3,
parent: 1,
detail: "child-b".to_string(),
},
],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
assert!(lines[3].text.contains("─ child-b"), "got {:?}", lines[3].text);
assert!(
lines[2].text.contains("─ child-a"),
"got {:?}",
lines[2].text
);
assert!(
lines[3].text.contains("└─ child-b"),
"got {:?}",
lines[3].text
);
// The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root"));
@@ -1775,7 +1800,10 @@ mod tests {
fn render_explain_plan_colours_a_full_scan_expensive() {
let plan = one_node_plan("SCAN Customers");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
assert_eq!(
span_class_for(&lines[1], "SCAN"),
OutputStyleClass::Expensive
);
// The table name stays neutral (ADR-0028 §6).
assert_eq!(
span_class_for(&lines[1], "Customers"),
@@ -1801,8 +1829,7 @@ mod tests {
#[test]
fn render_explain_plan_flags_an_automatic_index() {
let plan =
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let plan = one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
+29 -13
View File
@@ -150,7 +150,9 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
other => Err(format!("expected date/datetime (text), got {other:?}")),
},
Type::Blob => match value {
CellValue::Blob(bytes) => Ok(Cell::Plain(base64::engine::general_purpose::STANDARD.encode(bytes))),
CellValue::Blob(bytes) => Ok(Cell::Plain(
base64::engine::general_purpose::STANDARD.encode(bytes),
)),
other => Err(format!("expected blob, got {other:?}")),
},
Type::Serial => match value {
@@ -169,7 +171,11 @@ fn format_real(f: f64) -> String {
if f.is_nan() {
"nan".to_string()
} else if f.is_infinite() {
if f > 0.0 { "inf".to_string() } else { "-inf".to_string() }
if f > 0.0 {
"inf".to_string()
} else {
"-inf".to_string()
}
} else {
// Default `{}` formatting on f64 emits a shortest
// round-tripping decimal — exactly what the ADR asks
@@ -318,8 +324,7 @@ fn parse_field(bytes: &[u8]) -> Result<(RawCell, usize), CsvError> {
_ => i += 1,
}
}
let content =
String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
let content = String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
Ok((
RawCell {
content,
@@ -435,7 +440,10 @@ mod tests {
name: "T".to_string(),
columns: vec![col("n", Type::Int), col("r", Type::Real)],
rows: vec![
vec![CellValue::Integer(42), CellValue::Real(std::f64::consts::PI)],
vec![
CellValue::Integer(42),
CellValue::Real(std::f64::consts::PI),
],
vec![CellValue::Integer(-7), CellValue::Real(0.0)],
],
})
@@ -452,10 +460,7 @@ mod tests {
let body = serialize_table(&TableSnapshot {
name: "T".to_string(),
columns: vec![col("b", Type::Bool)],
rows: vec![
vec![CellValue::Integer(1)],
vec![CellValue::Integer(0)],
],
rows: vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(0)]],
})
.unwrap();
let s = String::from_utf8(body).unwrap();
@@ -555,13 +560,21 @@ mod tests {
let body = serialize_table(&table).unwrap();
let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap();
let row = &parsed.rows[0];
assert!(matches!(decode_cell(Type::Int, &row[0]).unwrap(), CellValue::Integer(42)));
assert!(matches!(
decode_cell(Type::Int, &row[0]).unwrap(),
CellValue::Integer(42)
));
match decode_cell(Type::Real, &row[1]).unwrap() {
CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12),
other => panic!("got {other:?}"),
}
assert!(matches!(decode_cell(Type::Bool, &row[2]).unwrap(), CellValue::Integer(1)));
assert!(matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi"));
assert!(matches!(
decode_cell(Type::Bool, &row[2]).unwrap(),
CellValue::Integer(1)
));
assert!(
matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")
);
}
#[test]
@@ -572,7 +585,10 @@ mod tests {
#[test]
fn decode_cell_reports_friendly_error_for_bad_int() {
let cell = RawCell { content: "abc".to_string(), was_quoted: false };
let cell = RawCell {
content: "abc".to_string(),
was_quoted: false,
};
let err = decode_cell(Type::Int, &cell).expect_err("must error");
assert!(err.contains("integer"));
assert!(err.contains("abc"));
+33 -18
View File
@@ -108,10 +108,7 @@ pub(super) fn read_recent_sources(
});
}
};
let mut sources: Vec<String> = body
.lines()
.filter_map(parse_record_source)
.collect();
let mut sources: Vec<String> = body.lines().filter_map(parse_record_source).collect();
if sources.len() > max_n {
let skip = sources.len() - max_n;
sources.drain(0..skip);
@@ -187,12 +184,26 @@ fn looks_like_iso8601(s: &str) -> bool {
return false;
}
let digit = |i: usize| b[i].is_ascii_digit();
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-'
&& digit(5) && digit(6) && b[7] == b'-'
&& digit(8) && digit(9) && b[10] == b'T'
&& digit(11) && digit(12) && b[13] == b':'
&& digit(14) && digit(15) && b[16] == b':'
&& digit(17) && digit(18) && b[19] == b'Z'
digit(0)
&& digit(1)
&& digit(2)
&& digit(3)
&& b[4] == b'-'
&& digit(5)
&& digit(6)
&& b[7] == b'-'
&& digit(8)
&& digit(9)
&& b[10] == b'T'
&& digit(11)
&& digit(12)
&& b[13] == b':'
&& digit(14)
&& digit(15)
&& b[16] == b':'
&& digit(17)
&& digit(18)
&& b[19] == b'Z'
}
fn unescape_command(s: &str) -> String {
@@ -321,10 +332,8 @@ mod tests {
#[test]
fn parse_journal_record_ok_extracts_unescaped_source() {
let rec = parse_journal_record(
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)",
)
.expect("valid ok journal record");
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)")
.expect("valid ok journal record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, "create table T with pk id(int)");
}
@@ -370,8 +379,8 @@ mod tests {
fn parse_journal_record_preserves_pipe_in_source() {
// `|` is not escaped by the writer (it's a valid SQL char);
// `splitn(3, '|')` keeps everything after the second `|`.
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t")
.expect("ok record");
let rec =
parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t").expect("ok record");
assert_eq!(rec.source, "select 'a|b' from t");
}
@@ -406,7 +415,10 @@ mod tests {
#[test]
fn iso8601_known_seconds() {
assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
assert_eq!(
iso8601_from_unix_secs(1_778_112_000),
"2026-05-07T00:00:00Z"
);
}
#[test]
@@ -437,7 +449,10 @@ mod tests {
.collect();
std::fs::write(&path, body).unwrap();
let got = read_recent_sources(&path, 3).unwrap();
assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]);
assert_eq!(
got,
vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]
);
}
#[test]
+37 -43
View File
@@ -82,7 +82,10 @@ impl Default for MigratorRegistry {
#[derive(Debug)]
pub enum MigrateError {
VersionParse(String),
NewerThanSupported { file: u32, latest: u32 },
NewerThanSupported {
file: u32,
latest: u32,
},
NoMigratorForVersion(u32),
StepFailed {
from: u32,
@@ -108,10 +111,9 @@ impl std::fmt::Display for MigrateError {
file = file,
latest = latest,
)),
Self::NoMigratorForVersion(v) => f.write_str(&crate::t!(
"persistence.migrate.no_migrator",
version = v,
)),
Self::NoMigratorForVersion(v) => {
f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,))
}
Self::StepFailed { from, to, source } => f.write_str(&crate::t!(
"persistence.migrate.step_failed",
from = from,
@@ -192,8 +194,11 @@ pub fn migrate_to_latest(
// Write the .bak before any transformation runs so a
// mid-migration crash leaves the original recoverable.
let bak_path =
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version));
let bak_path = project_path.join(format!(
"{}.v{}.bak",
crate::project::PROJECT_YAML,
file_version
));
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
path: bak_path.clone(),
source,
@@ -214,8 +219,8 @@ pub fn migrate_to_latest(
// Sanity: the new body must declare the next version.
// If a migrator forgets to bump, we'd loop endlessly
// through the chain — catch it here.
let advertised = read_version(&next_body)
.map_err(|e| MigrateError::BadOutput(e.to_string()))?;
let advertised =
read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?;
if advertised != v + 1 {
return Err(MigrateError::BadOutput(format!(
"v{v}→v{} migrator left version field at {advertised}",
@@ -281,9 +286,8 @@ fn read_version(body: &str) -> Result<u32, MigrateError> {
struct VersionOnly {
version: u32,
}
let v: VersionOnly = serde_norway::from_str(body).map_err(|e| {
MigrateError::VersionParse(e.to_string())
})?;
let v: VersionOnly =
serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?;
Ok(v.version)
}
@@ -309,12 +313,8 @@ mod tests {
#[test]
fn no_migration_runs_when_body_already_latest() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&MigratorRegistry::production(),
tmp.path(),
)
.unwrap();
let outcome =
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
assert_eq!(outcome.body, v1_body());
assert_eq!(outcome.migrated_from, None);
// No .bak written when nothing migrated.
@@ -328,7 +328,13 @@ mod tests {
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
.expect_err("must reject");
assert!(
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }),
matches!(
err,
MigrateError::NewerThanSupported {
file: 99,
latest: 1
}
),
"got: {err:?}",
);
}
@@ -366,12 +372,7 @@ mod tests {
#[test]
fn migrate_runs_chain_and_writes_bak() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&registry_with_v1_to_v2(),
tmp.path(),
)
.unwrap();
let outcome = migrate_to_latest(&v1_body(), &registry_with_v1_to_v2(), tmp.path()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
assert!(outcome.body.contains("version: 2"));
let bak = tmp.path().join("project.yaml.v1.bak");
@@ -396,11 +397,8 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
// File unchanged.
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
@@ -413,36 +411,32 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&registry_with_v1_to_v2(),
)
.unwrap();
let outcome = ensure_project_yaml_migrated(tmp.path(), &registry_with_v1_to_v2()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
assert!(on_disk.contains("version: 2"), "got: {on_disk}");
let bak = tmp.path().join("project.yaml.v1.bak");
assert!(bak.exists());
assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1"));
assert!(
std::fs::read_to_string(&bak)
.unwrap()
.contains("version: 1")
);
}
#[test]
fn ensure_yaml_migrated_handles_missing_yaml() {
let tmp = tempdir();
// No project.yaml exists.
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
assert!(outcome.body.is_empty());
}
#[test]
fn migrator_that_returns_internal_error_propagates() {
let bad: MigrateFn =
|_| Err(MigrateError::VersionParse("simulated".to_string()));
let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string()));
let registry = MigratorRegistry {
migrators: vec![bad],
};
+19 -19
View File
@@ -368,12 +368,11 @@ impl Persistence {
path: data_dir.clone(),
source,
})?;
let body =
csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body)
}
@@ -406,11 +405,8 @@ impl Persistence {
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_OK, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
let line =
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
debug!(
len = command_text.len(),
advanced, "persist: append ok record to history.log"
@@ -432,11 +428,8 @@ impl Persistence {
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_ERR, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
let line =
history::format_record_with_status(command_text, history::utc_iso8601_now(), &status);
debug!(
len = command_text.len(),
advanced, "persist: append err record to history.log"
@@ -531,8 +524,14 @@ mod tests {
#[test]
fn extension_with_tmp_appends_to_existing_extension() {
assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp");
assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp");
assert_eq!(
extension_with_tmp(Path::new("a/b/project.yaml")),
"yaml.tmp"
);
assert_eq!(
extension_with_tmp(Path::new("a/b/Customers.csv")),
"csv.tmp"
);
assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp");
}
@@ -600,7 +599,8 @@ mod tests {
fn append_history_creates_and_appends() {
let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf());
p.append_history("create table Foo with pk id(serial)", false).unwrap();
p.append_history("create table Foo with pk id(serial)", false)
.unwrap();
p.append_history("insert into Foo (1)", false).unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect();
+117 -35
View File
@@ -261,7 +261,10 @@ fn needs_quoting(s: &str) -> bool {
}
// Scalar text that looks like a YAML keyword needs quoting
// even if every character is safe.
if matches!(s, "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off") {
if matches!(
s,
"true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off"
) {
return true;
}
s.chars().any(|c| !is_safe_yaml_char(c))
@@ -287,13 +290,14 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
for t in raw.tables {
let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len());
for c in t.columns {
let user_type = c.user_type.parse::<Type>().map_err(|_| {
YamlError::UnknownType {
let user_type = c
.user_type
.parse::<Type>()
.map_err(|_| YamlError::UnknownType {
table: t.name.clone(),
column: c.name.clone(),
raw: c.user_type.clone(),
}
})?;
})?;
columns.push(ColumnSchema {
name: c.name,
user_type,
@@ -308,7 +312,11 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
primary_key: t.primary_key,
columns,
unique_constraints: t.unique_constraints,
check_constraints: t.check_constraints.into_iter().map(TableCheck::from).collect(),
check_constraints: t
.check_constraints
.into_iter()
.map(TableCheck::from)
.collect(),
});
}
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
@@ -381,10 +389,7 @@ pub(crate) enum YamlError {
impl std::fmt::Display for YamlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Syntax(msg) => f.write_str(&crate::t!(
"persistence.yaml.syntax",
detail = msg,
)),
Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)),
Self::UnsupportedVersion(v) => f.write_str(&crate::t!(
"persistence.yaml.unsupported_version",
version = v,
@@ -395,10 +400,9 @@ impl std::fmt::Display for YamlError {
column = column,
raw = raw,
)),
Self::UnknownAction(raw) => f.write_str(&crate::t!(
"persistence.yaml.unknown_action",
raw = raw,
)),
Self::UnknownAction(raw) => {
f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,))
}
}
}
}
@@ -545,8 +549,22 @@ mod tests {
name: "Customers".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Serial,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "Name".to_string(),
user_type: Type::Text,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -555,8 +573,22 @@ mod tests {
name: "Orders".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Serial,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "CustId".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -798,15 +830,33 @@ indexes:
name: "T".to_string(),
primary_key: vec![],
columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "a".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "b".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "c".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
TableCheck::unnamed("a < b"),
TableCheck::unnamed("b < c"),
],
check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")],
}],
relationships: vec![],
indexes: vec![],
@@ -830,12 +880,29 @@ indexes:
name: "T".to_string(),
primary_key: vec!["id".to_string()],
columns: vec![
ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "id".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "qty".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: vec![],
check_constraints: vec![
TableCheck { name: Some("qty_positive".to_string()), expr: "qty >= 0".to_string() },
TableCheck {
name: Some("qty_positive".to_string()),
expr: "qty >= 0".to_string(),
},
TableCheck::unnamed("qty < 1000"),
],
}],
@@ -844,7 +911,10 @@ indexes:
};
let body = serialize_schema(&snap);
let parsed = parse_schema(&body).expect("parse schema");
assert_eq!(parsed, snap, "named + unnamed table-CHECKs survive the yaml round-trip");
assert_eq!(
parsed, snap,
"named + unnamed table-CHECKs survive the yaml round-trip"
);
}
#[test]
@@ -968,8 +1038,22 @@ relationships:
name: "Items".to_string(),
primary_key: vec!["a".to_string(), "b".to_string()],
columns: vec![
ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None },
ColumnSchema {
name: "a".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
ColumnSchema {
name: "b".to_string(),
user_type: Type::Int,
unique: false,
not_null: false,
default: None,
check: None,
},
],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
@@ -1019,12 +1103,10 @@ relationships:
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
assert_eq!(parse_stored_mode(absent), None);
let explicit_simple =
"version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
let advanced =
"version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
}
+8 -2
View File
@@ -170,7 +170,10 @@ fn local_hostname() -> String {
/// Uses `sysinfo` to query the OS process table.
fn pid_is_alive(pid: u32) -> bool {
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true);
sys.refresh_processes(
sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
true,
);
sys.process(Pid::from_u32(pid)).is_some()
}
@@ -211,7 +214,10 @@ mod tests {
// The first lock writes our own PID; a second attempt
// should refuse because the PID is alive on this host.
let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition");
assert!(matches!(err, LockError::AlreadyHeld { .. }), "unexpected: {err:?}");
assert!(
matches!(err, LockError::AlreadyHeld { .. }),
"unexpected: {err:?}"
);
}
#[test]
+28 -29
View File
@@ -78,10 +78,7 @@ pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
/// a moved/deleted directory is the kind of error `--resume`
/// is supposed to surface clearly, not paper over by
/// resolving symlinks at write time.
pub fn write_last_project(
data_root: &Path,
project_path: &Path,
) -> std::io::Result<()> {
pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> {
fs::create_dir_all(data_root)?;
let final_path = data_root.join(LAST_PROJECT_FILE);
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
@@ -108,9 +105,8 @@ pub fn resolve_data_root(override_dir: Option<&Path>) -> Result<PathBuf, Project
if let Some(p) = override_dir {
return Ok(p.to_path_buf());
}
let dirs = ProjectDirs::from("", "", "rdbms-playground").ok_or(
ProjectError::DataRootUnavailable,
)?;
let dirs =
ProjectDirs::from("", "", "rdbms-playground").ok_or(ProjectError::DataRootUnavailable)?;
Ok(dirs.data_dir().to_path_buf())
}
@@ -255,21 +251,16 @@ pub enum ProjectError {
impl std::fmt::Display for ProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DataRootUnavailable => {
f.write_str(&crate::t!("project.data_root_unavailable"))
Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")),
Self::PathNotFound(p) => {
f.write_str(&crate::t!("project.path_not_found", path = p.display(),))
}
Self::NotAProject(p) => {
f.write_str(&crate::t!("project.not_a_project", path = p.display(),))
}
Self::AlreadyExists(p) => {
f.write_str(&crate::t!("project.already_exists", path = p.display(),))
}
Self::PathNotFound(p) => f.write_str(&crate::t!(
"project.path_not_found",
path = p.display(),
)),
Self::NotAProject(p) => f.write_str(&crate::t!(
"project.not_a_project",
path = p.display(),
)),
Self::AlreadyExists(p) => f.write_str(&crate::t!(
"project.already_exists",
path = p.display(),
)),
Self::Io { path, source } => f.write_str(&crate::t!(
"project.io",
path = path.display(),
@@ -609,11 +600,10 @@ pub fn safely_delete_temp_project(
// 2. Canonicalize for the containment check. We do this
// only after the symlink-at-top check so we can't be
// tricked by a top-level symlink.
let project_canon =
fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
path: project_path.to_path_buf(),
source,
})?;
let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
path: project_path.to_path_buf(),
source,
})?;
// 3. Containment: canonical path must be inside the
// canonical data-root projects dir.
@@ -848,7 +838,10 @@ mod tests {
assert!(gi.contains("/playground.db"));
assert!(gi.contains("/.rdbms-playground.lock"));
assert!(gi.contains("/.snapshots/"), "undo ring should be ignored");
assert!(!gi.contains("history.log"), "history.log should NOT be ignored");
assert!(
!gi.contains("history.log"),
"history.log should NOT be ignored"
);
}
#[test]
@@ -890,7 +883,10 @@ mod tests {
let target = tmp.path().join("MyProject");
fs::create_dir(&target).unwrap();
let err = Project::create_named(&target).expect_err("must refuse");
assert!(matches!(err, ProjectError::AlreadyExists(_)), "got: {err:?}");
assert!(
matches!(err, ProjectError::AlreadyExists(_)),
"got: {err:?}"
);
}
#[test]
@@ -962,7 +958,10 @@ mod tests {
)
.unwrap();
let read_back = read_last_project(tmp.path()).unwrap();
assert_eq!(read_back, Some(std::path::PathBuf::from("/tmp/some/project")));
assert_eq!(
read_back,
Some(std::path::PathBuf::from("/tmp/some/project"))
);
}
#[test]
+30 -15
View File
@@ -17,8 +17,8 @@
use std::path::Path;
use rand::seq::IndexedRandom;
use rand::Rng;
use rand::seq::IndexedRandom;
const WORDLIST: &str = include_str!("wordlist.txt");
const MAX_COLLISION_RETRIES: usize = 100;
@@ -41,10 +41,9 @@ pub enum NamingError {
impl std::fmt::Display for NamingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WordlistTooSmall(n) => f.write_str(&crate::t!(
"project.naming.wordlist_too_small",
count = n,
)),
Self::WordlistTooSmall(n) => {
f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,))
}
Self::TooManyCollisions(n) => f.write_str(&crate::t!(
"project.naming.too_many_collisions",
attempts = n,
@@ -189,10 +188,9 @@ impl std::fmt::Display for UserNameError {
match self {
Self::Empty => f.write_str(&crate::t!("project.user_name.empty")),
Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")),
Self::InvalidChar(c) => f.write_str(&crate::t!(
"project.user_name.invalid_char",
ch = c,
)),
Self::InvalidChar(c) => {
f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,))
}
}
}
}
@@ -209,14 +207,22 @@ mod tests {
#[test]
fn wordlist_has_enough_entries() {
let pool = words();
assert!(pool.len() >= 100, "wordlist suspiciously small: {} entries", pool.len());
assert!(
pool.len() >= 100,
"wordlist suspiciously small: {} entries",
pool.len()
);
}
#[test]
fn wordlist_has_no_duplicates() {
let pool = words();
let unique: std::collections::HashSet<_> = pool.iter().collect();
assert_eq!(unique.len(), pool.len(), "wordlist contains duplicate entries");
assert_eq!(
unique.len(),
pool.len(),
"wordlist contains duplicate entries"
);
}
#[test]
@@ -290,7 +296,7 @@ mod tests {
assert!(!is_temp_dirname("MyOrders"));
assert!(!is_temp_dirname("term_planner"));
assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker
assert!(!is_temp_dirname("temp-project")); // no brackets
assert!(!is_temp_dirname("temp-project")); // no brackets
}
#[test]
@@ -301,9 +307,18 @@ mod tests {
assert!(validate_user_name("project.v2").is_ok());
assert_eq!(validate_user_name(""), Err(UserNameError::Empty));
assert_eq!(validate_user_name(".hidden"), Err(UserNameError::LeadingDot));
assert!(matches!(validate_user_name("a/b"), Err(UserNameError::InvalidChar('/'))));
assert!(matches!(validate_user_name("a b"), Err(UserNameError::InvalidChar(' '))));
assert_eq!(
validate_user_name(".hidden"),
Err(UserNameError::LeadingDot)
);
assert!(matches!(
validate_user_name("a/b"),
Err(UserNameError::InvalidChar('/'))
));
assert!(matches!(
validate_user_name("a b"),
Err(UserNameError::InvalidChar(' '))
));
}
fn tempdir() -> tempfile::TempDir {
+8 -2
View File
@@ -129,7 +129,10 @@ mod tests {
#[test]
fn strips_date_prefix_from_temp_project_names() {
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
assert_eq!(
prettify("20260507-water-buffalo-skating"),
"Water Buffalo Skating"
);
}
#[test]
@@ -205,6 +208,9 @@ mod tests {
#[test]
fn handles_mixed_separators_and_case() {
assert_eq!(prettify("MyTeam_lessonPlan-2026"), "My Team Lesson Plan 2026");
assert_eq!(
prettify("MyTeam_lessonPlan-2026"),
"My Team Lesson Plan 2026"
);
}
}
+125 -149
View File
@@ -36,8 +36,8 @@ use crate::db::{
use crate::dsl::command::{
Constraint, ConstraintKind, IndexSelector, RelationshipSelector, TableConstraint,
};
use crate::dsl::{AlterTableAction, ChangeColumnMode, Command, ColumnSpec};
use crate::dsl::walker::Severity;
use crate::dsl::{AlterTableAction, ChangeColumnMode, ColumnSpec, Command};
use crate::event::AppEvent;
use crate::project::{
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
@@ -130,8 +130,7 @@ pub async fn run(args: Args) -> Result<()> {
// to it for `new` (creates a temp) and `load` (lists
// projects). We can't easily recover this from the
// Project alone, so we keep it ourselves.
let data_root = resolve_data_root(args.data_dir.as_deref())
.context("resolve data root")?;
let data_root = resolve_data_root(args.data_dir.as_deref()).context("resolve data root")?;
// Resolve the initial project path: --resume reads it from
// <data-root>/last_project; otherwise an explicit positional
@@ -143,17 +142,12 @@ pub async fn run(args: Args) -> Result<()> {
// terminal so the message lands directly in the user's
// shell.
let initial_path: Option<PathBuf> = if args.resume {
match read_last_project(&data_root)
.context("read last_project")?
{
match read_last_project(&data_root).context("read last_project")? {
Some(p) if p.exists() => Some(p),
Some(p) => {
eprintln!(
"rdbms-playground: {}",
crate::t!(
"project.resume_recorded_missing",
path = p.display(),
),
crate::t!("project.resume_recorded_missing", path = p.display(),),
);
return Ok(());
}
@@ -488,19 +482,15 @@ async fn run_loop(
// Best-effort — a failure to record a failure must
// never escalate a user error into a fatal, so the
// result is logged and ignored.
if let Err(e) = crate::persistence::Persistence::new(
session.project().path().to_path_buf(),
)
.append_history_failure(&source, advanced)
if let Err(e) =
crate::persistence::Persistence::new(session.project().path().to_path_buf())
.append_history_failure(&source, advanced)
{
tracing::warn!(error = %e, "failed to journal err record (ignored)");
}
}
Action::PrepareRebuild => {
spawn_prepare_rebuild(
session.project().path().to_path_buf(),
event_tx.clone(),
);
spawn_prepare_rebuild(session.project().path().to_path_buf(), event_tx.clone());
}
Action::Rebuild { source } => {
spawn_rebuild(
@@ -671,8 +661,8 @@ async fn run_loop(
// mutually exclusive (one needs an unmodified temp, the
// other anything else).
let project_at_quit = session.project.as_ref();
let cleanup_on_quit: Option<std::path::PathBuf> = project_at_quit
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
let cleanup_on_quit: Option<std::path::PathBuf> =
project_at_quit.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
let resume_target_on_quit: Option<std::path::PathBuf> = project_at_quit
.filter(|p| !p.is_unmodified_temp())
.map(|p| p.path().to_path_buf());
@@ -831,7 +821,10 @@ async fn perform_switch(
Some(p)
}
SwitchRequest::NewTemp => None,
SwitchRequest::Import { zip_path, as_target } => {
SwitchRequest::Import {
zip_path,
as_target,
} => {
if !zip_path.exists() {
return Err(crate::t!(
"project.import_zip_missing",
@@ -840,8 +833,7 @@ async fn perform_switch(
}
// Validate the zip up front so we don't drop the
// current project for an unimportable file.
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
let resolved = resolve_import_destination(
as_target.as_deref(),
&inspection.top_folder,
@@ -856,16 +848,19 @@ async fn perform_switch(
// state matches the in-memory db).
if let SwitchRequest::SaveAs { .. } = &req {
let src = session.project().path().to_path_buf();
let dst = resolved_target.as_ref().expect("SaveAs has resolved target");
let dst = resolved_target
.as_ref()
.expect("SaveAs has resolved target");
copy_project(&src, dst).map_err(|e| e.to_string())?;
}
// For Import: extract the zip into the resolved target.
// We do this *before* dropping the current project so
// a failure here leaves the user where they were.
if let SwitchRequest::Import { zip_path, .. } = &req {
let dst = resolved_target.as_ref().expect("Import has resolved target");
let inspection = crate::archive::inspect_zip(zip_path)
.map_err(|e| e.to_string())?;
let dst = resolved_target
.as_ref()
.expect("Import has resolved target");
let inspection = crate::archive::inspect_zip(zip_path).map_err(|e| e.to_string())?;
crate::archive::extract_into(zip_path, dst, &inspection.top_folder)
.map_err(|e| e.to_string())?;
}
@@ -874,10 +869,10 @@ async fn perform_switch(
// we drop it: if it was an unmodified empty temp, we
// delete its directory after the switch so the data dir
// doesn't accumulate empty scratch projects.
let outgoing_cleanup_path: Option<std::path::PathBuf> =
session.project.as_ref().and_then(|p| {
p.is_unmodified_temp().then(|| p.path().to_path_buf())
});
let outgoing_cleanup_path: Option<std::path::PathBuf> = session
.project
.as_ref()
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
// Drop current project + database BEFORE opening the new
// ones, releasing the old lock and stopping the old
@@ -954,9 +949,7 @@ async fn perform_switch(
let new_database =
Database::open_with_persistence_and_undo(&db_path, persistence, undo_enabled)
.map_err(|e| e.to_string())?;
if !db_existed
&& let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await
{
if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await {
return Err(e.friendly_message());
}
@@ -982,9 +975,7 @@ async fn perform_switch(
// fresh empty temp (a `new` command), which must not be
// recorded (see the gate in `run()`). Write failures are
// non-fatal.
if new_worth_recording
&& let Err(e) = write_last_project(&session.data_root, &new_path)
{
if new_worth_recording && let Err(e) = write_last_project(&session.data_root, &new_path) {
tracing::warn!(error = %e, "could not update last_project after switch");
}
@@ -1045,8 +1036,8 @@ fn spawn_export(
event_tx: mpsc::Sender<AppEvent>,
) {
// `export` app command: journalled simple (ADR-0052).
let _ = crate::persistence::Persistence::new(project_path.clone())
.append_history(&source, false);
let _ =
crate::persistence::Persistence::new(project_path.clone()).append_history(&source, false);
tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref())
@@ -1081,9 +1072,8 @@ fn do_export(
}
None => {
std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?;
let (filename, _) =
crate::archive::next_export_sequence(data_root, project_name)
.map_err(|e| e.to_string())?;
let (filename, _) = crate::archive::next_export_sequence(data_root, project_name)
.map_err(|e| e.to_string())?;
data_root.join(filename)
}
};
@@ -1143,10 +1133,7 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEve
/// no completion. Called wherever `TablesRefreshed` is sent
/// today; the schema cache lives on the App and feeds Tab
/// completion for identifier slots.
async fn refresh_schema_cache(
database: &Database,
event_tx: &mpsc::Sender<AppEvent>,
) {
async fn refresh_schema_cache(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
let cache = build_schema_cache(database).await;
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
// ADR-0046 DB2: full relationship records for the sidebar panel.
@@ -1234,10 +1221,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
/// summary that the confirmation modal shows. Runs off the
/// event loop so the brief I/O doesn't stall input handling
/// even on slow filesystems.
fn spawn_prepare_rebuild(
project_path: std::path::PathBuf,
event_tx: mpsc::Sender<AppEvent>,
) {
fn spawn_prepare_rebuild(project_path: std::path::PathBuf, event_tx: mpsc::Sender<AppEvent>) {
tokio::spawn(async move {
let summary = match summarize_project(&project_path) {
Ok(s) => s,
@@ -1317,9 +1301,7 @@ fn spawn_rebuild(
}
let summary = summarize_project(&project_path)
.unwrap_or_else(|_| "rebuild complete".to_string());
let _ = event_tx
.send(AppEvent::RebuildSucceeded { summary })
.await;
let _ = event_tx.send(AppEvent::RebuildSucceeded { summary }).await;
// Refresh the table list so the items panel
// reflects whatever the rebuild produced.
if let Ok(tables) = database.list_tables().await {
@@ -1462,12 +1444,8 @@ fn spawn_dsl_dispatch(
}
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo(
&command,
submission_mode,
description.as_ref(),
&lookups,
);
let schema_echo =
build_schema_echo(&command, submission_mode, description.as_ref(), &lookups);
AppEvent::DslSucceeded {
command: command.clone(),
description,
@@ -1484,12 +1462,10 @@ fn spawn_dsl_dispatch(
Ok(CommandOutcome::SchemaDropIndexSkipped) => AppEvent::DslDropIndexSkipped {
command: command.clone(),
},
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => {
AppEvent::DslCreateIndexSkipped {
command: command.clone(),
name,
}
}
Ok(CommandOutcome::SchemaCreateIndexSkipped(name)) => AppEvent::DslCreateIndexSkipped {
command: command.clone(),
name,
},
Ok(CommandOutcome::Query(data)) => {
// ADR-0038: `show data` is the only DSL-form query that
// echoes; its limited form orders by the table's primary
@@ -1507,12 +1483,10 @@ fn spawn_dsl_dispatch(
command: command.clone(),
lines,
},
Ok(CommandOutcome::ShowRelationship(data)) => {
AppEvent::DslShowRelationshipSucceeded {
command: command.clone(),
data: data.map(|b| *b),
}
}
Ok(CommandOutcome::ShowRelationship(data)) => AppEvent::DslShowRelationshipSucceeded {
command: command.clone(),
data: data.map(|b| *b),
},
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
command: command.clone(),
plan,
@@ -1568,11 +1542,8 @@ fn spawn_dsl_dispatch(
// the covering indexes the rebuild removed — Bucket B
// category 2, ADR-0038 §7 Slice 2b). Non-cascade falls
// through to the pre-execution `echo` from `echo_for`.
let cascade_echo = build_drop_column_cascade_echo(
&command,
submission_mode,
&result,
);
let cascade_echo =
build_drop_column_cascade_echo(&command, submission_mode, &result);
AppEvent::DslDropColumnSucceeded {
command: command.clone(),
result,
@@ -1931,12 +1902,14 @@ fn build_schema_echo(
)])
}
}
Command::DropRelationship { .. } => lookups
.drop_relationship
.as_ref()
.map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)]
}),
Command::DropRelationship { .. } => {
lookups
.drop_relationship
.as_ref()
.map(|(name, child_table)| {
vec![crate::echo::render_drop_relationship(name, child_table)]
})
}
// `create m:n relationship` (ADR-0045): the resolved junction
// columns/FKs only exist on the post-exec description, so the
// teaching echo is rendered from it (not `command_to_sql`).
@@ -1946,14 +1919,29 @@ fn build_schema_echo(
.iter()
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
.collect();
let primary_key: Vec<String> =
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
let primary_key: Vec<String> = desc
.columns
.iter()
.filter(|c| c.primary_key)
.map(|c| c.name.clone())
.collect();
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
.outbound_relationships
.iter()
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
.map(|r| {
(
r.local_columns.clone(),
r.other_table.clone(),
r.other_columns.clone(),
)
})
.collect();
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
vec![crate::echo::render_create_m2n(
&desc.name,
&columns,
&primary_key,
&foreign_keys,
)]
}),
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
// variants like `Sql*` / `ShowTable`) routes through the existing
@@ -2103,10 +2091,7 @@ async fn enrich_unique_violation(
facts
}
fn enrich_not_null_violation(
command: &Command,
message: &str,
) -> crate::friendly::FailureContext {
fn enrich_not_null_violation(command: &Command, message: &str) -> crate::friendly::FailureContext {
let mut facts = crate::friendly::FailureContext::default();
let Some((table, column)) = parse_qualified_target(message) else {
return facts;
@@ -2133,9 +2118,7 @@ async fn enrich_fk_violation(
// schema-aware lookup so natural-order multi-value
// INSERT (which `user_value_for_column` alone can't
// resolve) gets handled too.
let Ok((outbound, _)) =
database.read_relationships(table.clone()).await
else {
let Ok((outbound, _)) = database.read_relationships(table.clone()).await else {
return facts;
};
facts.table = Some(table.clone());
@@ -2173,8 +2156,7 @@ async fn enrich_fk_violation(
// children reference). Check inbound as a fallback.
if facts.parent_table.is_none()
&& matches!(command, Command::Update { .. })
&& let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
&& let Ok((_, inbound)) = database.read_relationships(table.clone()).await
&& let Some(rel) = inbound.first()
{
facts.child_table = Some(rel.other_table.clone());
@@ -2184,9 +2166,7 @@ async fn enrich_fk_violation(
// Parent-side: inbound FK lookup. Surface a child
// table that still references the row(s) being
// deleted.
let Ok((_, inbound)) =
database.read_relationships(table.clone()).await
else {
let Ok((_, inbound)) = database.read_relationships(table.clone()).await else {
return facts;
};
facts.table = Some(table.clone());
@@ -2271,10 +2251,7 @@ async fn user_value_for_column_with_schema(
..
} = command
{
let desc = database
.describe_table(table.to_string())
.await
.ok()?;
let desc = database.describe_table(table.to_string()).await.ok()?;
// Build the natural-order column list the same way
// `do_insert` does: filter out serial / shortid columns
// because the engine auto-fills them and the user's
@@ -2285,8 +2262,7 @@ async fn user_value_for_column_with_schema(
.filter(|c| {
!matches!(
c.user_type,
Some(crate::dsl::Type::Serial)
| Some(crate::dsl::Type::ShortId)
Some(crate::dsl::Type::Serial) | Some(crate::dsl::Type::ShortId)
)
})
.map(|c| c.name.as_str())
@@ -2310,10 +2286,7 @@ async fn user_value_for_column_with_schema(
&& listed_columns.is_empty()
&& literal_rows.len() == 1
{
let desc = database
.describe_table(table.to_string())
.await
.ok()?;
let desc = database.describe_table(table.to_string()).await.ok()?;
let idx = desc.columns.iter().position(|c| c.name == column)?;
return literal_rows[0].get(idx).cloned().flatten();
}
@@ -2323,16 +2296,12 @@ async fn user_value_for_column_with_schema(
/// Render a `DataResult` as a `DiagnosticTable` for the
/// friendly-error layer's bordered renderer (ADR-0019 §7,
/// reusing ADR-0017 §7's renderer).
fn diagnostic_from_data_result(
data: &DataResult,
) -> crate::friendly::DiagnosticTable {
use crate::output_render::{numeric_alignment_for, Alignment};
fn diagnostic_from_data_result(data: &DataResult) -> crate::friendly::DiagnosticTable {
use crate::output_render::{Alignment, numeric_alignment_for};
let alignments: Vec<Alignment> = data
.column_types
.iter()
.map(|t| {
t.map_or(Alignment::Left, numeric_alignment_for)
})
.map(|t| t.map_or(Alignment::Left, numeric_alignment_for))
.collect();
let rows: Vec<Vec<String>> = data
.rows
@@ -2543,9 +2512,7 @@ pub async fn run_replay(
// command, which was skipped above) — report it with the line
// number and stop.
let schema = build_schema_cache(database).await;
let command = match crate::dsl::parser::parse_command_with_schema(
&command_text, &schema,
) {
let command = match crate::dsl::parser::parse_command_with_schema(&command_text, &schema) {
Ok(c) => c,
Err(e) => {
events.push(AppEvent::ReplayFailed {
@@ -2566,8 +2533,7 @@ pub async fn run_replay(
// Retain a clone for failure enrichment (the command is moved into
// dispatch). ADR-0035 Amendment 1, F2 follow-up.
let command_for_ctx = command.clone();
let outcome =
execute_command_typed(database, command, command_text.clone()).await;
let outcome = execute_command_typed(database, command, command_text.clone()).await;
match outcome {
Ok(_) => {
// ADR-0052: journal the replayed line at the dispatch
@@ -2873,7 +2839,10 @@ async fn execute_command_typed(
.drop_constraint(table, column, ConstraintKind::NotNull, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
AlterTableAction::SetColumnDefault { column, default_sql } => database
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => database
.set_column_default(table, column, default_sql, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
@@ -2989,10 +2958,7 @@ async fn execute_command_typed(
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
// The grammar walker has already validated `sql` is in
// the supported subset; the worker runs it as text.
Command::Select { sql } => database
.run_select(sql)
.await
.map(CommandOutcome::Query),
Command::Select { sql } => database.run_select(sql).await.map(CommandOutcome::Query),
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
// text: the worker runs the validated `sql` and re-persists
// the parsed `target_table`'s CSV. Reuses the DSL insert
@@ -3112,12 +3078,9 @@ fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
Ok(terminal)
}
fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen).context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
}
@@ -3257,7 +3220,9 @@ mod tests {
// Limited → ORDER BY the resolved primary key.
assert_eq!(
super::build_show_data_echo(&db, &limited, EffectiveMode::AdvancedPersistent).await,
Some(vec!["SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()]),
Some(vec![
"SELECT * FROM Customers ORDER BY id LIMIT 5".to_string()
]),
);
// Simple mode → silent, gated before any lookup.
assert_eq!(
@@ -3288,10 +3253,10 @@ mod tests {
async fn bucket_b_resolved_name_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{ColumnSpec, IndexSelector, RelationshipSelector};
use crate::dsl::types::Type;
use crate::dsl::Command;
let db = Database::open(":memory:").expect("open in-memory");
db.create_table(
@@ -3319,7 +3284,12 @@ mod tests {
// --- add index (auto-named) ----------------------------------
let desc_after_add_index = db
.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.add_index(
None,
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("add index");
let add_idx_cmd = Command::AddIndex {
@@ -3439,7 +3409,10 @@ mod tests {
.await;
assert_eq!(
endpoints_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
Some((
"Customers_id_to_Orders_CustId".to_string(),
"Orders".to_string()
)),
"endpoints selector resolves name via child describe",
);
@@ -3454,7 +3427,10 @@ mod tests {
.await;
assert_eq!(
named_lookups.drop_relationship,
Some(("Customers_id_to_Orders_CustId".to_string(), "Orders".to_string())),
Some((
"Customers_id_to_Orders_CustId".to_string(),
"Orders".to_string()
)),
"named selector scans user tables to find the child",
);
@@ -3487,10 +3463,10 @@ mod tests {
async fn bucket_b_multi_statement_echoes_against_real_worker() {
use crate::app::EffectiveMode;
use crate::db::Database;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::ColumnSpec;
use crate::dsl::types::Type;
use crate::dsl::Command;
// --- drop column --cascade -----------------------------------
let db = Database::open(":memory:").expect("open in-memory");
@@ -3505,9 +3481,14 @@ mod tests {
)
.await
.expect("create Customers");
db.add_index(None, "Customers".to_string(), vec!["Email".to_string()], None)
.await
.expect("index Email");
db.add_index(
None,
"Customers".to_string(),
vec!["Email".to_string()],
None,
)
.await
.expect("index Email");
let drop_cmd = Command::DropColumn {
table: "Customers".to_string(),
@@ -3531,12 +3512,8 @@ mod tests {
);
// Simple mode → silent.
assert!(
super::build_drop_column_cascade_echo(
&drop_cmd,
EffectiveMode::Simple,
&drop_result,
)
.is_none(),
super::build_drop_column_cascade_echo(&drop_cmd, EffectiveMode::Simple, &drop_result,)
.is_none(),
);
// --- add relationship --create-fk (column newly created) ----
@@ -3673,11 +3650,11 @@ mod tests {
// switch (an unmodified temp would be cleaned up, taking its
// project.yaml with it). Without the unload persist the
// outgoing skeleton carries no `mode:` → `None`.
use super::{handle_project_switch, Session, SwitchRequest};
use super::{Session, SwitchRequest, handle_project_switch};
use crate::db::Database;
use crate::mode::Mode;
use crate::persistence::Persistence;
use crate::project::{projects_dir, Project};
use crate::project::{Project, projects_dir};
use tokio::sync::mpsc;
let data_root = tempfile::tempdir().unwrap();
@@ -3686,8 +3663,7 @@ mod tests {
let outgoing_path = projects.join("Outgoing");
let outgoing = Project::create_named(&outgoing_path).unwrap();
let db_path = outgoing.db_path();
let persistence =
Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
let persistence = Persistence::new(outgoing.path().to_path_buf()).with_mode(Mode::Advanced);
let database =
Database::open_with_persistence_and_undo(&db_path, persistence, true).unwrap();
let mut session = Session {
+7 -3
View File
@@ -18,7 +18,11 @@ pub fn parse_in_check_values(check: &str, column: &str) -> Option<Vec<String>> {
return None;
}
let values = extract_quoted_list(&check[paren_open..])?;
if values.is_empty() { None } else { Some(values) }
if values.is_empty() {
None
} else {
Some(values)
}
}
const fn is_ident_byte(b: u8) -> bool {
@@ -45,8 +49,8 @@ fn find_in_paren(check: &str) -> Option<(usize, usize)> {
i += 1;
continue;
}
let is_in = (b == b'i' || b == b'I')
&& bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
let is_in =
(b == b'i' || b == b'I') && bytes.get(i + 1).is_some_and(|n| *n == b'n' || *n == b'N');
if is_in {
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
let after = i + 2;
+127 -42
View File
@@ -81,17 +81,22 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
Generator::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => {
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
}
Generator::YearRecent => Value::Number(
rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR)
.to_string(),
),
Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(),
),
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Generator::DateAdult => {
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
Generator::DateRecent => {
Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS)))
}
Generator::DateAdult => Value::Text(format_date(random_past_date(
rng,
ADULT_MIN_DAYS,
ADULT_MAX_DAYS,
))),
Generator::DateTimeRecent => Value::Text(random_recent_datetime(rng)),
Generator::Boolean => Value::Bool(rng.random_range(0..2) == 1),
Generator::PickFrom(values) if !values.is_empty() => {
@@ -232,8 +237,7 @@ fn random_datetime_between(
} else {
rng.random_range(hi_s..=lo_s)
};
let dt = chrono::DateTime::from_timestamp(secs, 0)
.map_or(lo, |d| d.naive_utc());
let dt = chrono::DateTime::from_timestamp(secs, 0).map_or(lo, |d| d.naive_utc());
dt.format("%Y-%m-%dT%H:%M:%S").to_string()
}
@@ -294,20 +298,35 @@ fn currency_amount(ty: Type, rng: &mut SeedRng) -> Value {
// — the hand-rolled `product` generator (D9) —
const PRODUCT_ADJECTIVES: &[&str] = &[
"Sleek", "Rustic", "Ergonomic", "Handcrafted", "Refined", "Modern",
"Vintage", "Compact", "Premium", "Lightweight", "Durable", "Elegant",
"Sturdy", "Smooth", "Gorgeous", "Intelligent", "Practical", "Awesome",
"Incredible", "Recycled",
"Sleek",
"Rustic",
"Ergonomic",
"Handcrafted",
"Refined",
"Modern",
"Vintage",
"Compact",
"Premium",
"Lightweight",
"Durable",
"Elegant",
"Sturdy",
"Smooth",
"Gorgeous",
"Intelligent",
"Practical",
"Awesome",
"Incredible",
"Recycled",
];
const PRODUCT_MATERIALS: &[&str] = &[
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo",
"Plastic", "Ceramic", "Glass", "Concrete", "Rubber", "Bronze", "Marble",
"Linen", "Silk", "Aluminum", "Wool", "Gold", "Carbon",
"Wooden", "Copper", "Granite", "Cotton", "Steel", "Leather", "Bamboo", "Plastic", "Ceramic",
"Glass", "Concrete", "Rubber", "Bronze", "Marble", "Linen", "Silk", "Aluminum", "Wool", "Gold",
"Carbon",
];
const PRODUCT_NOUNS: &[&str] = &[
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug",
"Shoes", "Jacket", "Watch", "Wallet", "Bench", "Hat", "Gloves",
"Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
"Chair", "Lamp", "Table", "Bottle", "Backpack", "Keyboard", "Mug", "Shoes", "Jacket", "Watch",
"Wallet", "Bench", "Hat", "Gloves", "Towel", "Ball", "Bike", "Knife", "Pillow", "Blanket",
];
fn product_name(rng: &mut SeedRng) -> String {
@@ -396,7 +415,9 @@ mod tests {
] {
let v = gen_once(&generator, Type::Text, 3);
match v {
Value::Text(s) => assert!(!s.trim().is_empty(), "{generator:?} produced empty text"),
Value::Text(s) => {
assert!(!s.trim().is_empty(), "{generator:?} produced empty text")
}
other => panic!("{generator:?} produced non-text {other:?}"),
}
}
@@ -405,18 +426,25 @@ mod tests {
#[test]
fn email_looks_like_an_email() {
let v = gen_once(&Generator::Email, Type::Text, 11);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('@'), "email should contain @: {s}");
}
#[test]
fn product_name_is_three_capitalised_words() {
let v = gen_once(&Generator::ProductName, Type::Text, 99);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
let words: Vec<&str> = s.split(' ').collect();
assert_eq!(words.len(), 3, "product name should be 3 words: {s}");
for w in words {
assert!(w.chars().next().unwrap().is_ascii_uppercase(), "word `{w}` not capitalised");
assert!(
w.chars().next().unwrap().is_ascii_uppercase(),
"word `{w}` not capitalised"
);
}
}
@@ -429,9 +457,14 @@ mod tests {
let latest = reference_date();
for _ in 0..200 {
let v = generate_value(&Generator::DateRecent, Type::Date, &mut rng);
let Value::Text(s) = v else { panic!("date not text") };
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "date {d} outside recent window");
assert!(
d >= earliest && d <= latest,
"date {d} outside recent window"
);
}
}
@@ -446,7 +479,9 @@ mod tests {
.unwrap();
for _ in 0..200 {
let v = generate_value(&Generator::DateAdult, Type::Date, &mut rng);
let Value::Text(s) = v else { panic!("date not text") };
let Value::Text(s) = v else {
panic!("date not text")
};
let d = NaiveDate::parse_from_str(&s, "%Y-%m-%d").expect("valid ISO date");
assert!(d >= earliest && d <= latest, "dob {d} outside adult window");
}
@@ -455,7 +490,9 @@ mod tests {
#[test]
fn datetime_is_iso_shaped() {
let v = gen_once(&Generator::DateTimeRecent, Type::DateTime, 5);
let Value::Text(s) = v else { panic!("not text") };
let Value::Text(s) = v else {
panic!("not text")
};
assert!(s.contains('T'), "datetime needs a T separator: {s}");
// Parses as a naive datetime.
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
@@ -467,11 +504,17 @@ mod tests {
let Value::Number(int_amt) = gen_once(&Generator::CurrencyAmount, Type::Int, 4) else {
panic!("not a number")
};
assert!(!int_amt.contains('.'), "int currency should be whole: {int_amt}");
assert!(
!int_amt.contains('.'),
"int currency should be whole: {int_amt}"
);
let Value::Number(dec_amt) = gen_once(&Generator::CurrencyAmount, Type::Decimal, 4) else {
panic!("not a number")
};
assert!(dec_amt.contains('.'), "decimal currency should have cents: {dec_amt}");
assert!(
dec_amt.contains('.'),
"decimal currency should have cents: {dec_amt}"
);
}
#[test]
@@ -494,7 +537,10 @@ mod tests {
let Value::Text(s) = generate_value(&generator, Type::Text, &mut rng) else {
panic!("not text")
};
assert!(matches!(s.as_str(), "active" | "closed"), "unexpected pick {s}");
assert!(
matches!(s.as_str(), "active" | "closed"),
"unexpected pick {s}"
);
}
}
@@ -503,7 +549,10 @@ mod tests {
let generator = Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]);
let mut rng = make_rng(Some(6));
let v = generate_value(&generator, Type::Int, &mut rng);
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
assert!(
matches!(v, Value::Number(_)),
"numeric pick should be a Number: {v:?}"
);
}
#[test]
@@ -517,7 +566,10 @@ mod tests {
panic!("YearRecent must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
assert!(
(1950..=2025).contains(&n),
"YearRecent {n} out of [1950,2025]"
);
}
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
@@ -525,7 +577,10 @@ mod tests {
panic!("YearBirth must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
assert!(
(1945..=2007).contains(&n),
"YearBirth {n} out of [1945,2007]"
);
}
}
@@ -543,7 +598,10 @@ mod tests {
#[test]
fn int_range_stays_within_inclusive_bounds() {
let g = Generator::Range { low: "10".into(), high: "20".into() };
let g = Generator::Range {
low: "10".into(),
high: "20".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
@@ -556,7 +614,10 @@ mod tests {
#[test]
fn real_range_stays_within_bounds_and_has_cents() {
let g = Generator::Range { low: "1.0".into(), high: "9.0".into() };
let g = Generator::Range {
low: "1.0".into(),
high: "9.0".into(),
};
let mut rng = make_rng(Some(5));
for _ in 0..200 {
let Value::Number(s) = generate_value(&g, Type::Real, &mut rng) else {
@@ -588,13 +649,19 @@ mod tests {
#[test]
fn reversed_bounds_are_tolerated() {
let g = Generator::Range { low: "20".into(), high: "10".into() };
let g = Generator::Range {
low: "20".into(),
high: "10".into(),
};
let mut rng = make_rng(Some(1));
let Value::Number(s) = generate_value(&g, Type::Int, &mut rng) else {
panic!("number")
};
let n: i64 = s.parse().unwrap();
assert!((10..=20).contains(&n), "reversed bounds still produce in-range: {n}");
assert!(
(10..=20).contains(&n),
"reversed bounds still produce in-range: {n}"
);
}
#[test]
@@ -603,7 +670,10 @@ mod tests {
assert!(range_bounds_reason(Type::Int, "1", "10").is_none());
assert!(range_bounds_reason(Type::Real, "1.5", "9.9").is_none());
assert!(range_bounds_reason(Type::Date, "2023-01-01", "2024-01-01").is_none());
assert!(range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00").is_none());
assert!(
range_bounds_reason(Type::DateTime, "2023-01-01T00:00:00", "2024-01-01T00:00:00")
.is_none()
);
// Non-numeric bound on a numeric column.
assert!(range_bounds_reason(Type::Int, "abc", "10").is_some());
// A range on a text column is meaningless.
@@ -623,14 +693,29 @@ mod tests {
#[test]
fn generic_fallback_matches_each_type() {
let mut rng = make_rng(Some(0));
assert!(matches!(generate_value(&Generator::Generic, Type::Text, &mut rng), Value::Text(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Int, &mut rng), Value::Number(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Bool, &mut rng), Value::Bool(_)));
assert!(matches!(generate_value(&Generator::Generic, Type::Blob, &mut rng), Value::Null));
assert!(matches!(
generate_value(&Generator::Generic, Type::Text, &mut rng),
Value::Text(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Int, &mut rng),
Value::Number(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Bool, &mut rng),
Value::Bool(_)
));
assert!(matches!(
generate_value(&Generator::Generic, Type::Blob, &mut rng),
Value::Null
));
// shortid fallback is a valid base58 id.
let Value::Text(sid) = generate_value(&Generator::Generic, Type::ShortId, &mut rng) else {
panic!("shortid not text")
};
assert!(crate::dsl::shortid::validate(&sid).is_ok(), "invalid shortid {sid}");
assert!(
crate::dsl::shortid::validate(&sid).is_ok(),
"invalid shortid {sid}"
);
}
}
+181 -44
View File
@@ -63,8 +63,7 @@ pub fn is_enum_ish(name: &str) -> bool {
// `rating` / `stars` were never here. `status` stays — it is
// deliberately left to the advisory (no built-in set).
const ENUM_TOKENS: &[&str] = &[
"role", "status", "state", "type", "kind", "category", "level",
"tier", "stage", "gender",
"role", "status", "state", "type", "kind", "category", "level", "tier", "stage", "gender",
];
let toks = tokens(name);
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
@@ -81,9 +80,7 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
if text && (has_any(toks, &["fname", "firstname"]) || has_seq(toks, "first", "name")) {
return Some(Generator::FirstName);
}
if text
&& (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name"))
{
if text && (has_any(toks, &["lname", "lastname", "surname"]) || has_seq(toks, "last", "name")) {
return Some(Generator::LastName);
}
if text && (has_any(toks, &["username", "login", "handle"]) || has_seq(toks, "user", "name")) {
@@ -116,7 +113,10 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
// `province` / explicit `state_name`/`state_abbr` → a real state name.
// Bare `state` is left to enum-ish (it usually means status), so we
// require `province` or a `state` token paired with name/abbr.
if text && (has_token(toks, "province") || (has_token(toks, "state") && has_any(toks, &["name", "abbr"]))) {
if text
&& (has_token(toks, "province")
|| (has_token(toks, "state") && has_any(toks, &["name", "abbr"])))
{
return Some(Generator::StateName);
}
if text && has_any(toks, &["street", "address", "addr"]) {
@@ -127,7 +127,12 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Organisation / job —
if text && has_any(toks, &["company", "employer", "org", "organization", "organisation"]) {
if text
&& has_any(
toks,
&["company", "employer", "org", "organization", "organisation"],
)
{
return Some(Generator::Company);
}
if text && has_any(toks, &["job", "position", "profession", "occupation"]) {
@@ -135,7 +140,21 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Free text —
if text && has_any(toks, &["description", "bio", "notes", "note", "summary", "comment", "comments", "about"]) {
if text
&& has_any(
toks,
&[
"description",
"bio",
"notes",
"note",
"summary",
"comment",
"comments",
"about",
],
)
{
return Some(Generator::Sentence);
}
if text && has_any(toks, &["url", "website", "homepage", "link"]) {
@@ -146,7 +165,14 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
}
// — Numeric —
if numeric && has_any(toks, &["price", "amount", "cost", "salary", "balance", "total", "fee", "revenue"]) {
if numeric
&& has_any(
toks,
&[
"price", "amount", "cost", "salary", "balance", "total", "fee", "revenue",
],
)
{
return Some(Generator::CurrencyAmount);
}
if numeric && has_token(toks, "age") {
@@ -233,18 +259,50 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
fn name_by_table_context(table: &str) -> Generator {
let toks = tokens(table);
const PRODUCTY: &[&str] = &[
"product", "products", "item", "items", "good", "goods",
"merchandise", "catalog", "catalogue", "inventory", "sku", "skus",
"product",
"products",
"item",
"items",
"good",
"goods",
"merchandise",
"catalog",
"catalogue",
"inventory",
"sku",
"skus",
];
const COMPANYISH: &[&str] = &[
"company", "companies", "vendor", "vendors", "supplier",
"suppliers", "manufacturer", "manufacturers", "brand", "brands",
"organization", "organisation",
"company",
"companies",
"vendor",
"vendors",
"supplier",
"suppliers",
"manufacturer",
"manufacturers",
"brand",
"brands",
"organization",
"organisation",
];
const PERSONISH: &[&str] = &[
"user", "users", "customer", "customers", "person", "people",
"employee", "employees", "member", "members", "contact",
"contacts", "author", "authors", "student", "students",
"user",
"users",
"customer",
"customers",
"person",
"people",
"employee",
"employees",
"member",
"members",
"contact",
"contacts",
"author",
"authors",
"student",
"students",
];
if has_any(&toks, PRODUCTY) {
Generator::ProductName
@@ -264,9 +322,8 @@ fn name_by_table_context(table: &str) -> Generator {
/// before this guard; this catches structural names.
fn is_name_false_positive(toks: &[String]) -> bool {
const NON_PERSON: &[&str] = &[
"file", "table", "host", "domain", "field", "class", "tag",
"event", "path", "col", "column", "db", "schema", "index", "key",
"page", "node", "type",
"file", "table", "host", "domain", "field", "class", "tag", "event", "path", "col",
"column", "db", "schema", "index", "key", "page", "node", "type",
];
has_any(toks, NON_PERSON) && has_any(toks, &["name", "title"])
}
@@ -357,9 +414,18 @@ mod tests {
#[test]
fn person_name_fields_map_to_name_generators() {
assert_eq!(choose("users", "first_name", Type::Text), Generator::FirstName);
assert_eq!(choose("users", "firstName", Type::Text), Generator::FirstName);
assert_eq!(choose("users", "last_name", Type::Text), Generator::LastName);
assert_eq!(
choose("users", "first_name", Type::Text),
Generator::FirstName
);
assert_eq!(
choose("users", "firstName", Type::Text),
Generator::FirstName
);
assert_eq!(
choose("users", "last_name", Type::Text),
Generator::LastName
);
assert_eq!(choose("users", "surname", Type::Text), Generator::LastName);
}
@@ -368,9 +434,15 @@ mod tests {
assert_eq!(choose("users", "email", Type::Text), Generator::Email);
assert_eq!(choose("users", "work_email", Type::Text), Generator::Email);
assert_eq!(choose("users", "username", Type::Text), Generator::Username);
assert_eq!(choose("users", "user_name", Type::Text), Generator::Username);
assert_eq!(
choose("users", "user_name", Type::Text),
Generator::Username
);
assert_eq!(choose("users", "phone", Type::Text), Generator::Phone);
assert_eq!(choose("accounts", "password", Type::Text), Generator::Password);
assert_eq!(
choose("accounts", "password", Type::Text),
Generator::Password
);
}
#[test]
@@ -386,7 +458,10 @@ mod tests {
#[test]
fn bare_name_uses_table_context() {
// D11 — the same column name resolves differently by table.
assert_eq!(choose("products", "name", Type::Text), Generator::ProductName);
assert_eq!(
choose("products", "name", Type::Text),
Generator::ProductName
);
assert_eq!(choose("items", "title", Type::Text), Generator::ProductName);
assert_eq!(choose("users", "name", Type::Text), Generator::FullName);
assert_eq!(choose("customers", "name", Type::Text), Generator::FullName);
@@ -399,7 +474,10 @@ mod tests {
fn name_false_positives_do_not_become_person_names() {
// These must NOT resolve to a person/product name.
assert_ne!(choose("files", "filename", Type::Text), Generator::FullName);
assert_ne!(choose("meta", "table_name", Type::Text), Generator::FullName);
assert_ne!(
choose("meta", "table_name", Type::Text),
Generator::FullName
);
// They fall through to a generic / non-person generator.
assert_eq!(choose("files", "filename", Type::Text), Generator::Generic);
}
@@ -408,7 +486,10 @@ mod tests {
fn numeric_name_heuristics_are_type_gated() {
// `price` on a numeric column → currency; on text → falls through.
assert_eq!(choose("p", "price", Type::Int), Generator::CurrencyAmount);
assert_eq!(choose("p", "price", Type::Decimal), Generator::CurrencyAmount);
assert_eq!(
choose("p", "price", Type::Decimal),
Generator::CurrencyAmount
);
assert_eq!(choose("p", "price", Type::Text), Generator::Generic);
assert_eq!(choose("u", "age", Type::Int), Generator::Age);
assert_eq!(choose("o", "quantity", Type::Int), Generator::SmallInt);
@@ -425,8 +506,14 @@ mod tests {
fn temporal_fields_are_bounded_and_type_gated() {
assert_eq!(choose("u", "dob", Type::Date), Generator::DateAdult);
assert_eq!(choose("o", "order_date", Type::Date), Generator::DateRecent);
assert_eq!(choose("o", "created_at", Type::DateTime), Generator::DateTimeRecent);
assert_eq!(choose("o", "timestamp", Type::DateTime), Generator::DateTimeRecent);
assert_eq!(
choose("o", "created_at", Type::DateTime),
Generator::DateTimeRecent
);
assert_eq!(
choose("o", "timestamp", Type::DateTime),
Generator::DateTimeRecent
);
// Wrong type → not a date generator.
assert_eq!(choose("o", "order_date", Type::Int), Generator::Generic);
}
@@ -440,17 +527,32 @@ mod tests {
#[test]
fn identifier_family_is_unique_sequential() {
assert_eq!(choose("t", "code", Type::Text), Generator::IdentitySequential);
assert_eq!(choose("t", "sku", Type::Text), Generator::IdentitySequential);
assert_eq!(choose("t", "order_number", Type::Int), Generator::IdentitySequential);
assert_eq!(choose("t", "external_id", Type::Int), Generator::IdentitySequential);
assert_eq!(
choose("t", "code", Type::Text),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "sku", Type::Text),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "order_number", Type::Int),
Generator::IdentitySequential
);
assert_eq!(
choose("t", "external_id", Type::Int),
Generator::IdentitySequential
);
}
#[test]
fn foreign_key_columns_defer_to_executor() {
let mut spec = ColumnSpec::plain("user_id", Type::Int);
spec.is_foreign_key = true;
assert_eq!(choose_generator("orders", &spec), Generator::ForeignKeySample);
assert_eq!(
choose_generator("orders", &spec),
Generator::ForeignKeySample
);
}
#[test]
@@ -481,13 +583,28 @@ mod tests {
fn year_like_int_columns_map_to_bounded_years() {
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
// years pick the birth window; the rest a recent window.
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth);
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth);
assert_eq!(
choose("authors", "birth_year", Type::Int),
Generator::YearBirth
);
assert_eq!(
choose("authors", "birthYear", Type::Int),
Generator::YearBirth
);
assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent);
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent);
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent);
assert_eq!(
choose("films", "release_year", Type::Int),
Generator::YearRecent
);
assert_eq!(
choose("books", "published", Type::Int),
Generator::YearRecent
);
assert_eq!(
choose("companies", "founded", Type::Int),
Generator::YearRecent
);
// Type-gated: a text `year` is not a bounded-year int.
assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
// `year_count` is a count, not a year — the quantity rule wins.
@@ -507,7 +624,12 @@ mod tests {
);
assert_eq!(
choose("bugs", "severity", Type::Text),
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]),
Generator::PickFrom(vec![
"low".into(),
"medium".into(),
"high".into(),
"critical".into()
]),
);
assert_eq!(
choose("bugs", "severity", Type::Int),
@@ -515,11 +637,23 @@ mod tests {
);
assert_eq!(
choose("reviews", "rating", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
Generator::PickFrom(vec![
"1".into(),
"2".into(),
"3".into(),
"4".into(),
"5".into()
]),
);
assert_eq!(
choose("reviews", "stars", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
Generator::PickFrom(vec![
"1".into(),
"2".into(),
"3".into(),
"4".into(),
"5".into()
]),
);
}
@@ -552,7 +686,10 @@ mod tests {
#[test]
fn unmatched_columns_use_type_based_fallback() {
assert_eq!(choose("t", "some_freeform_field", Type::Text), Generator::Generic);
assert_eq!(
choose("t", "some_freeform_field", Type::Text),
Generator::Generic
);
}
#[test]
+5 -2
View File
@@ -32,7 +32,7 @@ mod vocabulary;
pub use check::parse_in_check_values;
pub use generators::{generate_value, range_bounds_reason};
pub use heuristics::{choose_generator, is_enum_ish};
pub use vocabulary::{generator_for_name, is_known_generator_prefix, KNOWN_GENERATORS};
pub use vocabulary::{KNOWN_GENERATORS, generator_for_name, is_known_generator_prefix};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
@@ -183,7 +183,10 @@ pub enum Generator {
/// does not parse for the column type is a friendly error), so
/// [`generate_value`] only ever sees parseable bounds; a defensive
/// parse failure falls back to type-based generation.
Range { low: String, high: String },
Range {
low: String,
high: String,
},
/// Type-based fallback (D8) when no name heuristic matches.
Generic,
}
+38 -29
View File
@@ -104,15 +104,15 @@ impl Theme {
// remains restful; literals and flags get warm
// accent tones; keyword takes a cool accent tone
// distinct from the mode-banner blue.
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_keyword: Color::Rgb(0xC7, 0x92, 0xEA), // muted purple
tok_identifier: Color::Rgb(0x56, 0xB6, 0xC2), // cyan-teal — identifiers are the user's content, deserve a vivid distinct colour
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
tok_type: Color::Rgb(0xF0, 0x8F, 0xC0), // pink — types sit in the red-purple range, clearly apart from the lavender keyword and teal identifier
tok_number: Color::Rgb(0xF7, 0x8C, 0x6C), // warm orange
tok_string: Color::Rgb(0xC3, 0xE8, 0x8D), // soft green
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
}
}
@@ -135,15 +135,15 @@ impl Theme {
// Light-theme token palette: same intent as dark —
// identifier/punct close to fg/muted; warm tones for
// literals + flags; cool accent for keyword.
tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple
tok_keyword: Color::Rgb(0x6F, 0x42, 0xC1), // royal purple
tok_identifier: Color::Rgb(0x0F, 0x6B, 0x76), // deep teal — same role as dark variant: identifiers stand out
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
tok_type: Color::Rgb(0xA8, 0x2D, 0x73), // deep magenta — red-purple, distinct from royal-purple keyword + teal identifier
tok_number: Color::Rgb(0xBC, 0x4F, 0x1F), // burnt orange
tok_string: Color::Rgb(0x22, 0x86, 0x3A), // forest green
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
}
}
@@ -192,10 +192,7 @@ mod tests {
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
c, t.bg,
"{name} must contrast against bg in dark theme",
);
assert_ne!(c, t.bg, "{name} must contrast against bg in dark theme",);
}
}
@@ -212,24 +209,36 @@ mod tests {
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
c, t.bg,
"{name} must contrast against bg in light theme",
);
assert_ne!(c, t.bg, "{name} must contrast against bg in light theme",);
}
}
#[test]
fn highlight_class_color_maps_each_variant() {
let t = Theme::dark();
assert_eq!(t.highlight_class_color(HighlightClass::Keyword), t.tok_keyword);
assert_eq!(t.highlight_class_color(HighlightClass::Identifier), t.tok_identifier);
assert_eq!(
t.highlight_class_color(HighlightClass::Keyword),
t.tok_keyword
);
assert_eq!(
t.highlight_class_color(HighlightClass::Identifier),
t.tok_identifier
);
assert_eq!(t.highlight_class_color(HighlightClass::Type), t.tok_type);
assert_eq!(t.highlight_class_color(HighlightClass::Number), t.tok_number);
assert_eq!(t.highlight_class_color(HighlightClass::String), t.tok_string);
assert_eq!(
t.highlight_class_color(HighlightClass::Number),
t.tok_number
);
assert_eq!(
t.highlight_class_color(HighlightClass::String),
t.tok_string
);
assert_eq!(t.highlight_class_color(HighlightClass::Punct), t.tok_punct);
assert_eq!(t.highlight_class_color(HighlightClass::Flag), t.tok_flag);
assert_eq!(t.highlight_class_color(HighlightClass::Function), t.tok_function);
assert_eq!(
t.highlight_class_color(HighlightClass::Function),
t.tok_function
);
assert_eq!(t.highlight_class_color(HighlightClass::Error), t.tok_error);
}
+18 -21
View File
@@ -87,9 +87,7 @@ pub fn static_refusal(src: Type, target: Type) -> Option<String> {
}
const fn is_in_matrix(src: Type, target: Type) -> bool {
use Type::{
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
matches!(
(src, target),
// Always-clean transformers
@@ -130,9 +128,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
if matches!(value, Value::Null) {
return CellOutcome::Clean(Value::Null);
}
use Type::{
Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text,
};
use Type::{Bool, Date, DateTime, Decimal, Int, Real, Serial, ShortId, Text};
match (src, target) {
// ---- Always-clean: int / serial source ----
(Int | Serial, Real) => match value {
@@ -179,9 +175,11 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
(Bool, Text) => match value {
// "true" / "false" matches the DSL boolean grammar
// (ADR-0014 §5), not raw 0/1 stringification.
Value::Integer(i) => CellOutcome::Clean(Value::Text(
if *i == 0 { "false".into() } else { "true".into() },
)),
Value::Integer(i) => CellOutcome::Clean(Value::Text(if *i == 0 {
"false".into()
} else {
"true".into()
})),
other => unexpected_storage("bool", other),
},
@@ -369,9 +367,7 @@ pub fn transform_cell(src: Type, target: Type, value: &Value) -> CellOutcome {
}
} else {
CellOutcome::Incompatible {
reason: format!(
"`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"
),
reason: format!("`{s}` is not a datetime in `YYYY-MM-DDTHH:MM:SS` form"),
}
}
}
@@ -450,10 +446,7 @@ fn real_to_int(r: f64) -> CellOutcome {
let discarded = r - r.trunc();
CellOutcome::Lossy {
new: Value::Integer(truncated),
reason: format!(
"truncated; would discard {}",
format_real(discarded)
),
reason: format!("truncated; would discard {}", format_real(discarded)),
}
}
}
@@ -555,9 +548,7 @@ fn format_real(r: f64) -> String {
fn unexpected_storage(label: &str, value: &Value) -> CellOutcome {
CellOutcome::Incompatible {
reason: format!(
"internal: cell stored unexpectedly for `{label}` source ({value:?})"
),
reason: format!("internal: cell stored unexpectedly for `{label}` source ({value:?})"),
}
}
@@ -638,7 +629,10 @@ mod tests {
(Type::Date, Type::Int),
(Type::ShortId, Type::Int),
] {
assert!(static_refusal(src, target).is_some(), "{src:?} -> {target:?}");
assert!(
static_refusal(src, target).is_some(),
"{src:?} -> {target:?}"
);
}
}
@@ -672,7 +666,10 @@ mod tests {
(Type::Bool, Type::Real),
];
for (s, t) in pairs {
assert_eq!(transform_cell(s, t, &Value::Null), CellOutcome::Clean(Value::Null));
assert_eq!(
transform_cell(s, t, &Value::Null),
CellOutcome::Clean(Value::Null)
);
}
}
+238 -135
View File
@@ -196,7 +196,16 @@ fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Fr
area.y + area.height - box_h - 1
}
};
fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame);
fill_overlay_rect(
Rect {
x,
y,
width: box_w,
height: box_h,
},
label.to_string(),
frame,
);
}
/// A step-caption box inset one cell from the bottom-right of `area`
@@ -309,7 +318,9 @@ fn render_path_entry(
let inner_w = dialog_w.saturating_sub(4) as usize;
let prompt_lines = wrap_lines(&m.prompt, inner_w);
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
let dialog_h = (prompt_lines.len() as u16)
.saturating_add(8)
.min(area.height);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
@@ -320,9 +331,7 @@ fn render_path_entry(
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -386,9 +395,7 @@ fn render_load_picker(
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -411,9 +418,7 @@ fn render_load_picker(
let marker = if i == m.selected { "" } else { " " };
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
let style = if i == m.selected {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
@@ -447,11 +452,7 @@ fn render_load_picker(
let display_input = if *cursor == input.len() {
format!("{input}{cursor_marker}")
} else {
format!(
"{}{cursor_marker}{}",
&input[..*cursor],
&input[*cursor..]
)
format!("{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..])
};
text_lines.push(Line::from(format!("> {display_input}")));
text_lines.push(Line::from(""));
@@ -500,9 +501,7 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
let bg = ratatui::widgets::Clear;
frame.render_widget(bg, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
@@ -524,16 +523,12 @@ fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, a
text_lines.push(Line::from(vec![
Span::styled(
"[Y]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled(
"[N]",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)),
@@ -578,18 +573,14 @@ where
/// dialog (issue #13): wide enough to hold the longest content
/// line on a single row, clamped to sane bounds and the available
/// area so a short insert no longer wraps on roomy terminals.
fn undo_dialog_width(
content_widths: impl IntoIterator<Item = usize>,
area_width: u16,
) -> u16 {
fn undo_dialog_width(content_widths: impl IntoIterator<Item = usize>, area_width: u16) -> u16 {
/// Floor — comfortably fits the button row plus borders.
const MIN: u16 = 34;
/// Ceiling for outlier (ultra-wide) terminals.
const MAX: u16 = 100;
let widest = content_widths.into_iter().max().unwrap_or(0);
// +4: left/right border (2) + one padding column each side (2).
let preferred =
u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let preferred = u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
let upper = area_width.min(MAX);
let lower = MIN.min(upper);
preferred.clamp(lower, upper)
@@ -617,8 +608,7 @@ fn render_undo_confirm(
let intro_line = format!("{intro} {}", m.command);
// Local-time, human-formatted snapshot stamp (issue #13).
let when_display = format_snapshot_timestamp(&m.timestamp);
let when_line =
crate::t!("modal.undo_confirm_when", timestamp = when_display);
let when_line = crate::t!("modal.undo_confirm_when", timestamp = when_display);
let prompt = crate::t!("modal.undo_confirm_prompt");
// Reconstruct the button row purely to measure its width — the
// styled spans are built below. Keep this in sync with them.
@@ -681,9 +671,15 @@ fn render_undo_confirm(
text_lines.push(Line::from(prompt));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::styled(
"[Y]",
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::styled(
"[N]",
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(
@@ -811,17 +807,13 @@ fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec<String> {
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let label_style = Style::default().fg(theme.muted);
let value_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
let no_project = crate::t!("status.no_project");
let display = app.project_name.as_deref().unwrap_or(no_project.as_str());
let mut spans: Vec<Span<'_>> = vec![Span::styled(
crate::t!("status.project_label"),
label_style,
)];
let mut spans: Vec<Span<'_>> =
vec![Span::styled(crate::t!("status.project_label"), label_style)];
if app.project_is_temp {
spans.push(Span::styled(
"[TEMP] ",
@@ -875,9 +867,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -918,9 +908,7 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
let mut lines: Vec<Line<'_>> = Vec::new();
for name in &app.tables {
let style = if name == highlight {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
};
@@ -937,7 +925,9 @@ fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
}
}
}
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((offset as u16, 0));
frame.render_widget(paragraph, area);
}
@@ -956,9 +946,7 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
))
.title(Span::styled(
format!(" {} ", crate::t!("panel.relationships_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -992,12 +980,24 @@ fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_
ellipsize(&rel.name, inner_w),
name_style,
)));
let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style)));
let parent = format!(
" {}.{} ->",
rel.parent_table,
rel.parent_columns.join(", ")
);
lines.push(Line::from(Span::styled(
ellipsize(&parent, inner_w),
detail_style,
)));
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
lines.push(Line::from(Span::styled(
ellipsize(&child, inner_w),
detail_style,
)));
}
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((offset as u16, 0));
frame.render_widget(paragraph, area);
}
@@ -1022,9 +1022,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.output_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -1132,9 +1130,9 @@ const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
OutputStyleClass::Neutral => Style::new().fg(theme.fg),
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
OutputStyleClass::Expensive => Style::new().fg(theme.warning),
OutputStyleClass::AutomaticIndex => Style::new()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
OutputStyleClass::AutomaticIndex => {
Style::new().fg(theme.warning).add_modifier(Modifier::BOLD)
}
// ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:`
// prefix and every category-3 prose line (caveat + the
// existing `client_side.*` notes). `theme.muted` is the
@@ -1239,9 +1237,7 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
&line.text[..prefix_len],
Style::default().fg(theme.muted),
));
for run in
crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced)
{
for run in crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) {
spans.push(Span::styled(
&rest[run.byte_range.0..run.byte_range.1],
run.style,
@@ -1350,9 +1346,7 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
Span::raw(" "),
Span::styled(
label,
Style::default()
.fg(mode_color)
.add_modifier(Modifier::BOLD),
Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
@@ -1474,7 +1468,10 @@ fn render_input_one_row(
if offset > 0 {
frame.render_widget(
Paragraph::new(Span::styled("<", marker)),
Rect { width: 1, ..text_area },
Rect {
width: 1,
..text_area
},
);
}
if offset + eff < line_cols {
@@ -1536,8 +1533,16 @@ fn render_input_two_rows(
// Overflowing both rows reserves a marker column on each row's
// outer edge; otherwise both rows use their full text width.
let overflow = line_cols >= capacity;
let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w };
let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w };
let row0_text_w = if overflow {
row0_w.saturating_sub(1)
} else {
row0_w
};
let row1_text_w = if overflow {
row1_w.saturating_sub(1)
} else {
row1_w
};
let eff_cap = row0_text_w + row1_text_w;
let start = offset.min(len);
@@ -1552,7 +1557,11 @@ fn render_input_two_rows(
)
};
let row0_x = if overflow { text_area.x + 1 } else { text_area.x };
let row0_x = if overflow {
text_area.x + 1
} else {
text_area.x
};
frame.render_widget(
Paragraph::new(to_line(&window[..split])),
Rect {
@@ -1622,10 +1631,7 @@ fn expand_runs_to_cells(
/// Convert `StyledRun`s into ratatui `Span`s borrowed from
/// `input`. The end-of-input cursor sentinel (empty range) is
/// rendered as an inverted space.
fn runs_to_spans<'a>(
input: &'a str,
runs: &[crate::input_render::StyledRun],
) -> Vec<Span<'a>> {
fn runs_to_spans<'a>(input: &'a str, runs: &[crate::input_render::StyledRun]) -> Vec<Span<'a>> {
runs.iter()
.map(|r| {
if r.byte_range.0 == r.byte_range.1 {
@@ -1710,21 +1716,14 @@ fn resolve_hint_lines(
}
}
fn render_hint_panel(
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
lines: Vec<Line<'static>>,
) {
fn render_hint_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect, lines: Vec<Line<'static>>) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.hint_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
@@ -1918,9 +1917,7 @@ fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
}
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let key_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let key_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(theme.muted);
let label_style = Style::default().fg(theme.muted);
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
@@ -2016,9 +2013,16 @@ mod tests {
};
let rendered = render_output_line(&line, &theme);
// [system] tag, then the dim prefix, then ≥1 SQL spans.
assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans);
assert!(
rendered.spans.len() >= 3,
"tag + prefix + sql: {:?}",
rendered.spans
);
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL);
assert_eq!(
rendered.spans[1].content.as_ref(),
crate::echo::TEACHING_ECHO_LABEL
);
assert_eq!(
rendered.spans[1].style.fg,
Some(theme.muted),
@@ -2152,17 +2156,41 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark();
let items = vec![
Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both },
Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple },
Candidate {
text: "table".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Both,
},
Candidate {
text: "index".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
Candidate {
text: "relationship".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Simple,
},
];
let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!(line.spans[0].content.as_ref(), "table");
assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour");
assert_eq!(
line.spans[0].style.fg,
Some(theme.tok_keyword),
"Both keeps the kind colour"
);
assert_eq!(line.spans[2].content.as_ref(), "index");
assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour");
assert_eq!(
line.spans[2].style.fg,
Some(theme.mode_advanced),
"Advanced → advanced colour"
);
assert_eq!(line.spans[4].content.as_ref(), "relationship");
assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour");
assert_eq!(
line.spans[4].style.fg,
Some(theme.mode_simple),
"Simple → simple colour"
);
}
#[test]
@@ -2173,8 +2201,16 @@ mod tests {
use crate::completion::{Candidate, CandidateKind, ModeClass};
let theme = Theme::dark();
let items = vec![
Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
Candidate {
text: "values".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
Candidate {
text: "select".into(),
kind: CandidateKind::Keyword,
mode: ModeClass::Advanced,
},
];
let line = render_candidate_line(&items, None, 100, &theme);
assert_eq!(
@@ -2248,7 +2284,10 @@ mod tests {
"the error body is neutral fg, not flooded red",
);
assert!(
rendered.spans[1].style.add_modifier.contains(Modifier::BOLD),
rendered.spans[1]
.style
.add_modifier
.contains(Modifier::BOLD),
"the error body is bold for weight without the red-wall readability cost",
);
}
@@ -2509,10 +2548,14 @@ mod tests {
"the tail around the cursor must be visible:\n{out}"
);
assert!(
!out.lines().any(|l| l.contains("select * from Customers where")),
!out.lines()
.any(|l| l.contains("select * from Customers where")),
"the head must be scrolled off:\n{out}"
);
assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}");
assert!(
out.contains('<'),
"a left scroll marker signals the hidden head:\n{out}"
);
}
#[test]
@@ -2525,9 +2568,18 @@ mod tests {
let theme = Theme::dark();
// Narrow (sidebar hidden, DB1) so the line overflows the field.
let out = render_to_string(&mut app, &theme, 60, 24);
assert!(out.contains("select * from"), "head visible at Home:\n{out}");
assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}");
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}");
assert!(
out.contains("select * from"),
"head visible at Home:\n{out}"
);
assert!(
out.contains('>'),
"a right scroll marker signals the hidden tail:\n{out}"
);
assert!(
!out.contains("Wonderland"),
"the tail must be scrolled off:\n{out}"
);
}
// ---- ADR-0046 DA4: two-row input on tall terminals -----------
@@ -2569,8 +2621,14 @@ mod tests {
let theme = Theme::dark();
// Very narrow + tall: two rows, but the line exceeds both.
let out = render_to_string(&mut app, &theme, 38, 44);
assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}");
assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}");
assert!(
out.contains("Wonderland"),
"the tail/cursor stays visible:\n{out}"
);
assert!(
out.contains('<'),
"a left marker signals the hidden head:\n{out}"
);
}
#[test]
@@ -2644,7 +2702,10 @@ mod tests {
/// The `key` column of the strip's bindings, in order.
fn strip_keys(app: &App) -> Vec<&'static str> {
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
status_bar_bindings(app)
.into_iter()
.map(|(k, _)| k)
.collect()
}
/// The full rendered strip text (keys + labels + separators).
@@ -2659,7 +2720,12 @@ mod tests {
fn hint_text(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
@@ -2686,10 +2752,7 @@ mod tests {
fn strip_sidebar_focus_state_is_pane_scroll_input() {
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
assert_eq!(
strip_keys(&app),
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
);
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],);
// ...and the relationships sidebar is the same state.
app.nav_focus = NavFocus::SidebarRelationships;
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
@@ -2802,11 +2865,7 @@ mod tests {
assert_eq!(two, vec!["alpha beta", "gamma delta"]);
// > max rows: clamp to max, last row ends with an ellipsis,
// and every row stays within the width.
let many = clamp_wrapped(
"alpha beta gamma delta epsilon zeta eta theta iota",
11,
3,
);
let many = clamp_wrapped("alpha beta gamma delta epsilon zeta eta theta iota", 11, 3);
assert_eq!(many.len(), 3);
assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}");
for row in &many {
@@ -2931,9 +2990,18 @@ mod tests {
app.output.push_back(err);
let out = render_to_string(&mut app, &Theme::dark(), 100, 20);
assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}");
assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}");
assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}");
assert!(
out.contains("running: drop table Orders"),
"pending keeps running::\n{out}"
);
assert!(
out.contains("create table T with pk ✓"),
"ok shows ✓:\n{out}"
);
assert!(
out.contains("insert into T values (1) ✗"),
"err shows ✗:\n{out}"
);
assert!(
!out.contains("running: create table"),
"a completed echo drops the running: prefix:\n{out}"
@@ -2970,7 +3038,10 @@ mod tests {
#[test]
fn format_snapshot_timestamp_falls_back_on_garbage() {
assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp");
assert_eq!(
format_snapshot_timestamp("not a timestamp"),
"not a timestamp"
);
}
#[test]
@@ -2999,9 +3070,9 @@ mod tests {
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(
out.lines().any(|l| l.contains(
"This will undo: insert into Customers values (1, 'Oliver Sturm')"
)),
out.lines()
.any(|l| l
.contains("This will undo: insert into Customers values (1, 'Oliver Sturm')")),
"command must sit on one row on a wide terminal:\n{out}"
);
}
@@ -3017,7 +3088,10 @@ mod tests {
}));
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 120, 30);
assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}");
assert!(
out.contains("Snapshot taken"),
"capitalized Snapshot:\n{out}"
);
assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}");
assert!(out.contains("[N] No"), "capitalized No:\n{out}");
assert!(
@@ -3113,8 +3187,14 @@ mod tests {
app.schema_cache.table_indexes.insert(
"Customers".to_string(),
vec![
IndexEntry { name: "idx_email".to_string(), unique: false },
IndexEntry { name: "uidx_login".to_string(), unique: true },
IndexEntry {
name: "idx_email".to_string(),
unique: false,
},
IndexEntry {
name: "uidx_login".to_string(),
unique: true,
},
],
);
let theme = Theme::dark();
@@ -3123,7 +3203,10 @@ mod tests {
assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "table listed:\n{out}");
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
assert!(
out.contains("uidx_login [unique]"),
"unique index marked:\n{out}"
);
}
#[test]
@@ -3143,10 +3226,19 @@ mod tests {
app.tables = vec!["Customers".to_string()];
let theme = Theme::dark();
let narrow = render_to_string(&mut app, &theme, 80, 24);
assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}");
assert!(
!narrow.contains("Tables"),
"sidebar hidden at 80 wide:\n{narrow}"
);
let wide = render_to_string(&mut app, &theme, 110, 24);
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}");
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}");
assert!(
wide.contains("Tables"),
"sidebar shown at 110 wide:\n{wide}"
);
assert!(
wide.contains("Customers"),
"tables listed when shown:\n{wide}"
);
}
#[test]
@@ -3181,7 +3273,10 @@ mod tests {
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 110, 24);
assert!(out.contains("Relationships"), "panel title present:\n{out}");
assert!(out.contains("Customers_Orders"), "relationship name:\n{out}");
assert!(
out.contains("Customers_Orders"),
"relationship name:\n{out}"
);
assert!(
out.lines().any(|l| l.contains("Customers.id ->")),
"parent endpoint, broken at the arrow:\n{out}"
@@ -3228,8 +3323,14 @@ mod tests {
app.nav_focus = NavFocus::SidebarTables;
let focused = render_to_string(&mut app, &theme, 80, 24);
assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}");
assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}");
assert!(
focused.contains("Tables"),
"sidebar revealed in nav mode:\n{focused}"
);
assert!(
focused.contains("Customers"),
"tables in the overlay:\n{focused}"
);
assert!(
focused.contains("Relationships"),
"relationships panel in the overlay:\n{focused}"
@@ -3365,7 +3466,9 @@ mod tests {
}
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal.draw(|f| render(app, theme, f)).expect("draw frame");
terminal
.draw(|f| render(app, theme, f))
.expect("draw frame");
terminal.backend().buffer().clone()
}
+15 -3
View File
@@ -737,7 +737,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root)
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert_eq!(payload_dirs, 2, "ring capped at 2 payloads");
}
@@ -766,7 +770,11 @@ mod tests {
let payload_dirs = fs::read_dir(&store.root)
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_str().is_some_and(|n| n.parse::<u64>().is_ok()))
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert_eq!(payload_dirs, 1, "only the surviving undo payload remains");
}
@@ -820,6 +828,10 @@ mod tests {
fs::create_dir_all(store.payload_dir(41)).unwrap();
stage_finalize(&store, &fx.conn, "cmd");
let meta = store.peek_undo().unwrap().unwrap();
assert!(meta.id >= 42, "id allocated above the orphan, got {}", meta.id);
assert!(
meta.id >= 42,
"id allocated above the orphan, got {}",
meta.id
);
}
}
+47 -13
View File
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
@@ -78,7 +77,10 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
let r = rt();
r.block_on(db.create_table(
"Items".to_string(),
vec![ColumnSpec::new("id", Type::Int), ColumnSpec::new("qty", Type::Int)],
vec![
ColumnSpec::new("id", Type::Int),
ColumnSpec::new("qty", Type::Int),
],
vec!["id".to_string()],
Some("create".to_string()),
))
@@ -129,7 +131,11 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
.block_on(db.query_data("Items".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
assert_eq!(
rows.len(),
1,
"the wrong-case insert survived the rebuild (no data loss)"
);
assert_eq!(rows[0][1].as_deref(), Some("kept"));
}
@@ -146,9 +152,19 @@ fn add_column_with_case_variant_table_survives_rebuild() {
);
let db = fresh_rebuild(db, &project, &r);
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe");
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
let desc = r
.block_on(db.describe_table("Items".to_string()))
.expect("describe");
let qty = desc
.columns
.iter()
.find(|c| c.name == "qty")
.expect("qty added");
assert_eq!(
qty.user_type,
Some(Type::Int),
"qty's user-type survived the rebuild"
);
// The CHECK is intact too (a negative qty is refused under the real table).
assert!(
r.block_on(db.insert(
@@ -175,9 +191,15 @@ fn drop_table_with_case_variant_name_clears_table_and_csv() {
insert into Items (id, note) values (1, 'x')\n\
drop table items\n",
);
assert!(!tables(&db, &r).contains(&"Items".to_string()), "the table was dropped");
assert!(
!tables(&db, &r).contains(&"Items".to_string()),
"the table was dropped"
);
let csv = project.path().join(project::DATA_DIR).join("Items.csv");
assert!(!csv.exists(), "the CSV was removed despite the case-variant drop");
assert!(
!csv.exists(),
"the CSV was removed despite the case-variant drop"
);
// A fresh rebuild yields no Items (the metadata/yaml has no orphan).
let db = fresh_rebuild(db, &project, &r);
@@ -224,12 +246,24 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() {
add 1:n relationship from parent.id to child.parent_id\n",
);
// The parent's inbound relationship is visible under the stored case.
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
let p = r
.block_on(db.describe_table("Parent".to_string()))
.expect("describe Parent");
assert_eq!(
p.inbound_relationships.len(),
1,
"relationship recorded under the stored case"
);
assert_eq!(p.inbound_relationships[0].other_table, "Child");
let db = fresh_rebuild(db, &project, &r);
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
let p = r
.block_on(db.describe_table("Parent".to_string()))
.expect("describe Parent");
assert_eq!(
p.inbound_relationships.len(),
1,
"relationship survived the rebuild"
);
assert_eq!(p.inbound_relationships[0].other_table, "Child");
}
+22 -8
View File
@@ -24,8 +24,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence_and_undo(project.db_path(), persistence, true)
.expect("open db with persistence");
@@ -48,7 +47,9 @@ fn make_t_with_check(db: &Database, r: &tokio::runtime::Runtime) {
vec!["a < b".to_string()],
vec![],
false,
Some("create table T (id int primary key, a int, b int, c text, check (a < b))".to_string()),
Some(
"create table T (id int primary key, a int, b int, c text, check (a < b))".to_string(),
),
))
.expect("create T with table CHECK");
}
@@ -285,7 +286,10 @@ fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name()
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.expect_err("dropping a composite-UNIQUE column is refused");
let msg = err.friendly_message();
assert!(msg.contains("unique_a_b"), "names the derived constraint; got: {msg}");
assert!(
msg.contains("unique_a_b"),
"names the derived constraint; got: {msg}"
);
assert!(
msg.contains("drop constraint unique_a_b"),
"points at the actionable drop command; got: {msg}"
@@ -351,14 +355,24 @@ fn rename_column_with_a_column_level_check_is_refused() {
make_t_with_column_checks(&db, &r);
// `qty`'s own check `qty >= 0` references qty → refused.
assert!(
r.block_on(db.rename_column("T".to_string(), "qty".to_string(), "amount".to_string(), None))
.is_err(),
r.block_on(db.rename_column(
"T".to_string(),
"qty".to_string(),
"amount".to_string(),
None
))
.is_err(),
"renaming a column with its own column-level CHECK is refused"
);
// `price` is referenced by `discount`'s check `discount < price`.
assert!(
r.block_on(db.rename_column("T".to_string(), "price".to_string(), "cost".to_string(), None))
.is_err(),
r.block_on(db.rename_column(
"T".to_string(),
"price".to_string(),
"cost".to_string(),
None
))
.is_err(),
"renaming a column referenced by another column's CHECK is refused"
);
// `id` is referenced by no CHECK → rename succeeds.
+51 -28
View File
@@ -9,7 +9,7 @@
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{
parse_command, ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value,
ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value, parse_command,
};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
@@ -18,10 +18,8 @@ use rdbms_playground::project;
#[test]
fn parenthesized_compound_endpoint_parses_to_column_lists() {
let cmd = parse_command(
"add 1:n relationship from Parent.(a, b) to Child.(x, y)",
)
.expect("parses");
let cmd =
parse_command("add 1:n relationship from Parent.(a, b) to Child.(x, y)").expect("parses");
match cmd {
Command::AddRelationship {
parent_table,
@@ -41,8 +39,7 @@ fn parenthesized_compound_endpoint_parses_to_column_lists() {
#[test]
fn single_column_endpoint_still_parses_unparenthesized() {
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid")
.expect("parses");
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid").expect("parses");
match cmd {
Command::AddRelationship {
parent_columns,
@@ -148,7 +145,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
],
None,
)
.await
@@ -157,7 +157,10 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
.insert(
"City".to_string(),
Some(vec!["country".to_string(), "region_code".to_string()]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
vec![
Value::Number("9".to_string()),
Value::Number("9".to_string()),
],
None,
)
.await;
@@ -176,8 +179,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
@@ -241,7 +243,10 @@ fn compound_fk_declares_enforces_and_round_trips() {
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
],
None,
)
.await
@@ -253,7 +258,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
"region_code".to_string(),
"name".to_string(),
]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string()), Value::Text("Metropolis".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
Value::Text("Metropolis".to_string()),
],
None,
)
.await
@@ -266,7 +275,11 @@ fn compound_fk_declares_enforces_and_round_trips() {
"region_code".to_string(),
"name".to_string(),
]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string()), Value::Text("Nowhere".to_string())],
vec![
Value::Number("9".to_string()),
Value::Number("9".to_string()),
Value::Text("Nowhere".to_string()),
],
None,
)
.await;
@@ -360,7 +373,10 @@ fn compound_fk_arity_mismatch_is_refused() {
None,
)
.await;
assert!(err.is_err(), "mismatched child/parent arity must be refused");
assert!(
err.is_err(),
"mismatched child/parent arity must be refused"
);
});
}
@@ -386,10 +402,8 @@ fn inline_fk_referencing_compound_pk_points_at_table_level_form() {
.expect("create Region");
// Parse the inline form so the `inline` flag is set by the grammar.
let cmd = parse_command(
"create table City (country int references Region(country, code))",
)
.expect("parses");
let cmd = parse_command("create table City (country int references Region(country, code))")
.expect("parses");
let Command::SqlCreateTable {
name,
columns,
@@ -465,7 +479,10 @@ fn compound_fk_type_mismatch_per_pair_is_refused() {
None,
)
.await;
assert!(err.is_err(), "a type-incompatible column pair must be refused");
assert!(
err.is_err(),
"a type-incompatible column pair must be refused"
);
});
}
@@ -478,11 +495,8 @@ fn compound_fk_survives_rebuild_from_text() {
let path = project.path().to_path_buf();
let rt = rt();
{
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.expect("open db");
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.expect("open db");
rt.block_on(async {
seed_compound(&db).await;
db.add_relationship(
@@ -512,7 +526,10 @@ fn compound_fk_survives_rebuild_from_text() {
db.insert(
"Region".to_string(),
Some(vec!["country".to_string(), "code".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("10".to_string()),
],
None,
)
.await
@@ -521,11 +538,17 @@ fn compound_fk_survives_rebuild_from_text() {
.insert(
"City".to_string(),
Some(vec!["country".to_string(), "region_code".to_string()]),
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
vec![
Value::Number("9".to_string()),
Value::Number("9".to_string()),
],
None,
)
.await;
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
assert!(
bad.is_err(),
"compound FK still enforced after rebuild from text"
);
// Endpoints survived the round-trip intact.
let city = db.describe_table("City".to_string()).await.unwrap();
assert_eq!(
+4 -9
View File
@@ -32,10 +32,8 @@ use rdbms_playground::event::AppEvent;
const FORBIDDEN: &[&str] = &[
// Product names.
"SQLite", "sqlite",
// Crate name.
"rusqlite",
// Engine-specific keywords / idioms.
"SQLite", "sqlite", // Crate name.
"rusqlite", // Engine-specific keywords / idioms.
"STRICT", "PRAGMA",
];
@@ -52,9 +50,7 @@ fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> {
fn assert_clean(label: &str, s: &str) {
if let Some((needle, pos)) = engine_vocab_leak(s) {
panic!(
"ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}"
);
panic!("ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}");
}
}
@@ -118,8 +114,7 @@ fn parse_errors_use_no_engine_vocabulary() {
"this is not a command",
];
for input in inputs {
let err = parse_command(input)
.expect_err(&format!("expected parse failure for `{input}`"));
let err = parse_command(input).expect_err(&format!("expected parse failure for `{input}`"));
let rendered = format!("{err:?}");
assert_clean(&format!("parse error for `{input}`"), &rendered);
}
+59 -25
View File
@@ -18,10 +18,10 @@
use tokio::runtime::Runtime;
use rdbms_playground::db::{Database, DbError, SqliteErrorKind};
use rdbms_playground::dsl::{
action::ReferentialAction, ColumnSpec, Command, RowFilter, Type, Value,
};
use rdbms_playground::dsl::parser::parse_command;
use rdbms_playground::dsl::{
ColumnSpec, Command, RowFilter, Type, Value, action::ReferentialAction,
};
use rdbms_playground::runtime::enrich_dsl_failure;
fn rt() -> Runtime {
@@ -57,7 +57,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("5".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -86,7 +89,10 @@ fn enrich_unique_insert_resolves_table_column_value_and_pinpoint() {
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -169,7 +175,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("5".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("5".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -189,7 +198,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
else {
panic!("expected Command::SqlInsert, got {cmd:?}");
};
assert!(listed_columns.is_empty(), "natural-order form has no column list");
assert!(
listed_columns.is_empty(),
"natural-order form has no column list"
);
let err = db
.run_sql_insert_with_literals(
sql,
@@ -204,7 +216,10 @@ fn enrich_unique_sql_insert_natural_order_resolves_value_via_schema() {
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -235,7 +250,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("1".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -243,7 +261,10 @@ fn enrich_unique_update_resolves_value_from_assignments() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
vec![
Value::Number("2".to_string()),
Value::Text("Bob".to_string()),
],
None,
)
.await
@@ -294,7 +315,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Text("Alice".to_string())],
vec![
Value::Number("1".to_string()),
Value::Text("Alice".to_string()),
],
None,
)
.await
@@ -302,7 +326,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
db.insert(
"Customers".to_string(),
None,
vec![Value::Number("2".to_string()), Value::Text("Bob".to_string())],
vec![
Value::Number("2".to_string()),
Value::Text("Bob".to_string()),
],
None,
)
.await
@@ -328,7 +355,10 @@ fn enrich_unique_sql_update_resolves_value_from_set_literals() {
.unwrap_err();
assert!(matches!(
err,
DbError::Sqlite { kind: SqliteErrorKind::UniqueViolation, .. }
DbError::Sqlite {
kind: SqliteErrorKind::UniqueViolation,
..
}
));
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
@@ -666,7 +696,10 @@ fn enrich_fk_delete_resolves_child_table() {
db.insert(
"Orders".to_string(),
None,
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await
@@ -708,16 +741,15 @@ fn enrich_check_insert_resolves_table_column_value_and_rule() {
)
.await
.unwrap();
let score_spec = match parse_command(
"create table __probe with pk score(int) check (score >= 0)",
)
.expect("probe create parses")
{
Command::CreateTable { columns, .. } => {
columns.into_iter().next().expect("one column")
}
other => panic!("expected CreateTable, got {other:?}"),
};
let score_spec =
match parse_command("create table __probe with pk score(int) check (score >= 0)")
.expect("probe create parses")
{
Command::CreateTable { columns, .. } => {
columns.into_iter().next().expect("one column")
}
other => panic!("expected CreateTable, got {other:?}"),
};
db.add_column("Scores".to_string(), score_spec, None)
.await
.unwrap();
@@ -757,7 +789,9 @@ fn enrich_unsupported_returns_default_facts() {
let db = db();
rt().block_on(async {
let err = DbError::Unsupported("nope".to_string());
let cmd = Command::DropTable { name: "X".to_string() };
let cmd = Command::DropTable {
name: "X".to_string(),
};
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
assert!(facts.table.is_none());
assert!(facts.column.is_none());
+1 -1
View File
@@ -11,7 +11,7 @@
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::app::App;
use rdbms_playground::dsl::{parse_command, AppCommand, Command};
use rdbms_playground::dsl::{AppCommand, Command, parse_command};
use rdbms_playground::event::AppEvent;
const fn key(code: KeyCode) -> AppEvent {
+22 -11
View File
@@ -14,9 +14,7 @@ use std::path::Path;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, DATA_DIR, PROJECT_YAML,
};
use rdbms_playground::project::{self, DATA_DIR, PROJECT_YAML};
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
@@ -33,9 +31,7 @@ fn rt() -> tokio::runtime::Runtime {
/// `Database` (with persistence wired) plus the path so the
/// test can inspect on-disk state. The project is held alive
/// implicitly via the leaked `TempDir` returned alongside.
fn open_project(
data: &tempfile::TempDir,
) -> (project::Project, Database, std::path::PathBuf) {
fn open_project(data: &tempfile::TempDir) -> (project::Project, Database, std::path::PathBuf) {
let project = project::open_or_create(None, Some(data.path())).expect("open project");
let path = project.path().to_path_buf();
let persistence = Persistence::new(path.clone());
@@ -72,7 +68,10 @@ fn create_table_writes_yaml_and_history() {
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
assert!(
yaml.contains("- name: Customers"),
"yaml missing table:\n{yaml}"
);
assert!(yaml.contains("primary_key: [id]"), "yaml: {yaml}");
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}");
@@ -151,9 +150,15 @@ fn drop_table_removes_its_csv() {
.unwrap();
});
assert!(read_csv(&path, "Customers").is_none(), "CSV should be deleted");
assert!(
read_csv(&path, "Customers").is_none(),
"CSV should be deleted"
);
let yaml = read_yaml(&path);
assert!(!yaml.contains("- name: Customers"), "table should be gone from yaml:\n{yaml}");
assert!(
!yaml.contains("- name: Customers"),
"table should be gone from yaml:\n{yaml}"
);
}
#[test]
@@ -263,7 +268,10 @@ fn create_table_does_not_write_csv_for_empty_table() {
// Schema landed in YAML.
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers"), "yaml missing table:\n{yaml}");
assert!(
yaml.contains("- name: Customers"),
"yaml missing table:\n{yaml}"
);
// ...but no CSV until there's data.
assert!(
read_csv(&path, "Customers").is_none(),
@@ -394,7 +402,10 @@ fn project_yaml_carries_relationship_after_add() {
});
let yaml = read_yaml(&path);
assert!(yaml.contains("- name: Customers_id_to_Orders_CustId"), "yaml: {yaml}");
assert!(
yaml.contains("- name: Customers_id_to_Orders_CustId"),
"yaml: {yaml}"
);
assert!(yaml.contains("on_delete: cascade"), "yaml: {yaml}");
assert!(yaml.contains("on_update: no_action"), "yaml: {yaml}");
}
+28 -37
View File
@@ -35,11 +35,8 @@ fn rebuild_restores_schema_only_project() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -89,11 +86,8 @@ fn rebuild_restores_rows_from_csv() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -157,11 +151,8 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -244,7 +235,11 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
})
.expect("delete");
assert_eq!(result.rows_affected, 1);
assert_eq!(result.cascade.len(), 1, "expected one cascade entry: {result:?}");
assert_eq!(
result.cascade.len(),
1,
"expected one cascade entry: {result:?}"
);
assert_eq!(result.cascade[0].child_table, "Orders");
}
@@ -256,11 +251,8 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Numbers".to_string(),
@@ -303,13 +295,17 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
.unwrap();
let err = rt()
.block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
})
.expect_err("must fail with row-level error");
let msg = format!("{err}");
assert!(msg.contains("row 2"), "msg should name the row: {msg}");
assert!(msg.contains("Numbers"), "msg should name the table: {msg}");
assert!(msg.contains("integer"), "msg should explain the type mismatch: {msg}");
assert!(
msg.contains("integer"),
"msg should explain the type mismatch: {msg}"
);
}
#[test]
@@ -318,11 +314,8 @@ fn rebuild_preserves_created_at_from_yaml() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"T".to_string(),
@@ -371,9 +364,7 @@ fn rebuild_preserves_created_at_from_yaml() {
// Trigger any successful command so project.yaml is
// rewritten from the now-rebuilt db state.
rt().block_on(async {
db.describe_table("T".to_string())
.await
.unwrap();
db.describe_table("T".to_string()).await.unwrap();
// describe is read-only; force a rewrite by adding a column.
db.add_column(
"T".to_string(),
@@ -400,11 +391,8 @@ fn rebuild_restores_indexes() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -434,7 +422,10 @@ fn rebuild_restores_indexes() {
// The index must be recorded in project.yaml — the `.db` is
// a derived artifact and gets discarded next.
let yaml = fs::read_to_string(project_path.join(project::PROJECT_YAML)).unwrap();
assert!(yaml.contains("idx_email"), "yaml should record the index:\n{yaml}");
assert!(
yaml.contains("idx_email"),
"yaml should record the index:\n{yaml}"
);
fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
+9 -7
View File
@@ -113,7 +113,10 @@ fn modal_swallows_unrelated_keys() {
// field while the modal is up.
app.update(key(KeyCode::Char('x')));
assert!(app.input.is_empty(), "modal should swallow key input");
assert!(app.modal.is_some(), "modal still active after unrelated key");
assert!(
app.modal.is_some(),
"modal still active after unrelated key"
);
}
#[test]
@@ -122,11 +125,8 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
let project_path = {
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -156,7 +156,9 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
// Hand-edit the CSV to introduce a different row content.
// Rebuild should pick up the edited content.
let csv_path = project_path.join("data").join("Customers.csv");
let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna");
let edited = fs::read_to_string(&csv_path)
.unwrap()
.replace("Alice", "Edna");
fs::write(&csv_path, edited).unwrap();
// Reopen with persistence (the .db still exists but has
+3 -6
View File
@@ -16,9 +16,9 @@ use rdbms_playground::app::{
App, LoadPickerEntry, LoadPickerModal, LoadPickerSubMode, Modal, PathEntryModal,
PathEntryPurpose,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type};
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, Project, ProjectKind, copy_project, safely_delete_temp_project,
@@ -462,11 +462,8 @@ fn temp_with_a_table_is_no_longer_unmodified() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let db =
Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())).unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
+10 -8
View File
@@ -14,8 +14,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::archive::{
default_export_filename, export_project, extract_into, inspect_zip,
next_export_sequence, resolve_import_target,
default_export_filename, export_project, extract_into, inspect_zip, next_export_sequence,
resolve_import_target,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML};
@@ -295,11 +295,9 @@ fn end_to_end_export_then_import_real_project() {
// Build a populated source project.
let src_path = {
let p = project::Project::create_named(&data.path().join("Source")).unwrap();
let db = Database::open_with_persistence(
p.db_path(),
Persistence::new(p.path().to_path_buf()),
)
.unwrap();
let db =
Database::open_with_persistence(p.db_path(), Persistence::new(p.path().to_path_buf()))
.unwrap();
rt().block_on(async {
db.create_table(
"Customers".to_string(),
@@ -362,7 +360,11 @@ fn end_to_end_export_then_import_real_project() {
// Round-trip: the inserted row is back.
let data_view = rt()
.block_on(async { imported_db.query_data("Customers".to_string(), None, None).await })
.block_on(async {
imported_db
.query_data("Customers".to_string(), None, None)
.await
})
.expect("query data");
assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value.
+2 -1
View File
@@ -166,7 +166,8 @@ fn hydration_reads_both_ok_and_err_records() {
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk", false).unwrap();
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
p.append_history_failure("insert into A (1, 2, 3)", false)
.unwrap();
p.append_history("show data A", false).unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
+165 -45
View File
@@ -8,7 +8,7 @@
use rdbms_playground::db::Database;
use rdbms_playground::dsl::command::RowFilter;
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
use rdbms_playground::dsl::{ColumnSpec, Command, Type, Value, parse_command};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, PLAYGROUND_DB};
@@ -22,8 +22,11 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let project = project::open_or_create(None, Some(dir.path())).expect("project");
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.expect("db");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("db");
(project, db, dir)
}
@@ -45,7 +48,10 @@ fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
async fn serial_pk_table(db: &Database, name: &str) {
db.create_table(
name.to_string(),
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("label", Type::Text),
],
vec!["id".to_string()],
None,
)
@@ -84,7 +90,9 @@ fn parses_with_as_name() {
match parse_command("create m:n relationship from Students to Courses as Enrollments")
.expect("parses")
{
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
Command::CreateM2nRelationship { name, .. } => {
assert_eq!(name.as_deref(), Some("Enrollments"))
}
other => panic!("expected CreateM2nRelationship, got {other:?}"),
}
}
@@ -104,12 +112,21 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
// Auto-named `Students_Courses` exists.
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
assert!(
tables.contains(&"Students_Courses".to_string()),
"tables: {tables:?}"
);
// Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
let desc = db
.describe_table("Students_Courses".to_string())
.await
.unwrap();
let cols: Vec<(&str, bool)> = desc
.columns
.iter()
.map(|c| (c.name.as_str(), c.primary_key))
.collect();
assert_eq!(
cols,
vec![("Students_id", true), ("Courses_id", true)],
@@ -124,7 +141,10 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await
@@ -134,21 +154,33 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await;
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
assert!(
dup.is_err(),
"duplicate (Students_id, Courses_id) must be refused"
);
// A link to a non-existent parent is refused by the FK.
let orphan = db
.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("99".to_string()),
],
None,
)
.await;
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
assert!(
orphan.is_err(),
"link to a non-existent Course must be refused"
);
});
}
@@ -167,7 +199,10 @@ fn as_name_overrides_the_junction_table_name() {
.await
.expect("create m:n as Enrollments");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(
tables.contains(&"Enrollments".to_string()),
"tables: {tables:?}"
);
assert!(!tables.contains(&"Students_Courses".to_string()));
});
}
@@ -179,7 +214,10 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
// Sections has a 2-column PK (course_id, term).
db.create_table(
"Sections".to_string(),
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
vec![
ColumnSpec::new("course_id", Type::Int),
ColumnSpec::new("term", Type::Int),
],
vec!["course_id".to_string(), "term".to_string()],
None,
)
@@ -191,11 +229,20 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
.await
.expect("create m:n");
let desc = db.describe_table("Students_Sections".to_string()).await.unwrap();
let desc = db
.describe_table("Students_Sections".to_string())
.await
.unwrap();
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
assert_eq!(
names,
vec!["Students_id", "Sections_course_id", "Sections_term"]
);
// All three form the compound PK.
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
assert!(
desc.columns.iter().all(|c| c.primary_key),
"all columns are PK: {names:?}"
);
});
}
@@ -213,16 +260,28 @@ fn deleting_a_parent_cascades_to_the_junction() {
db.insert(
"Students_Courses".to_string(),
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
vec![
Value::Number("1".to_string()),
Value::Number("1".to_string()),
],
None,
)
.await
.unwrap();
// Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap();
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
db.delete("Students".to_string(), RowFilter::AllRows, None)
.await
.unwrap();
let rows = db
.query_data("Students_Courses".to_string(), None, None)
.await
.unwrap();
assert!(
rows.rows.is_empty(),
"junction rows should cascade-delete, got {:?}",
rows.rows
);
});
}
@@ -242,15 +301,26 @@ fn create_m2n_is_one_undo_step() {
)
.await
.unwrap();
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
assert!(
db.list_tables()
.await
.unwrap()
.contains(&"Students_Courses".to_string())
);
// One undo removes the junction table AND both relationships.
db.undo().await.unwrap();
let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
assert!(
!tables.contains(&"Students_Courses".to_string()),
"undo should remove the junction: {tables:?}"
);
// The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string()).await.unwrap();
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
assert!(
students.inbound_relationships.is_empty(),
"no leftover relationship after undo"
);
});
}
@@ -265,7 +335,10 @@ fn self_referential_m2n_is_refused() {
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
.await
.expect_err("self m:n must be refused");
assert!(format!("{err}").contains("two different tables"), "got: {err}");
assert!(
format!("{err}").contains("two different tables"),
"got: {err}"
);
});
}
@@ -275,11 +348,19 @@ fn missing_parent_table_is_refused() {
rt().block_on(async {
serial_pk_table(&db, "Students").await;
let err = db
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
.create_m2n_relationship(
"Students".to_string(),
"Nonexistent".to_string(),
None,
None,
)
.await
.expect_err("a missing parent table must be refused");
// The standard "no such table" guard (require_canonical_table).
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
assert!(
format!("{err}").to_lowercase().contains("no such table"),
"got: {err}"
);
});
}
@@ -297,7 +378,10 @@ fn junction_name_collision_is_refused() {
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.expect_err("a junction-name collision must be refused");
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
assert!(
format!("{err}").to_lowercase().contains("exist"),
"got: {err}"
);
});
}
@@ -314,15 +398,26 @@ fn the_junction_can_be_renamed() {
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
.await
.unwrap();
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
.await
.expect("rename the junction");
db.rename_table(
"Students_Courses".to_string(),
"Enrollments".to_string(),
None,
)
.await
.expect("rename the junction");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(
tables.contains(&"Enrollments".to_string()),
"tables: {tables:?}"
);
assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
assert_eq!(
desc.outbound_relationships.len(),
2,
"FKs preserved across rename"
);
});
}
@@ -355,16 +450,33 @@ fn junction_survives_save_and_rebuild() {
// Discard the derived .db so the next open rebuilds from text.
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
let project = project::Project::open(&project_path).unwrap();
let db =
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
.unwrap();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.unwrap();
rt().block_on(async {
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
db.rebuild_from_text(project.path().to_path_buf(), None)
.await
.expect("rebuild");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
assert!(
tables.contains(&"Students_Courses".to_string()),
"junction survived: {tables:?}"
);
let desc = db
.describe_table("Students_Courses".to_string())
.await
.unwrap();
assert_eq!(
desc.outbound_relationships.len(),
2,
"both FKs reconstructed"
);
assert!(
desc.columns.iter().all(|c| c.primary_key),
"compound PK reconstructed"
);
});
}
@@ -387,7 +499,12 @@ fn as_an_internal_name_is_refused() {
.await
.expect_err("an internal junction name must be refused");
assert!(format!("{err}").contains("no such table"), "got: {err}");
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
assert!(
!db.list_tables()
.await
.unwrap()
.contains(&"__rdbms_evil".to_string())
);
});
}
@@ -442,7 +559,10 @@ fn read_all_relationships_returns_the_junction_relationships() {
);
// Both have the junction (Students_Courses) as their child.
for r in &rels {
assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}");
assert_eq!(
r.child_table, "Students_Courses",
"child is the junction: {r:?}"
);
}
// One points back to each parent.
let parents: std::collections::BTreeSet<&str> =
+1 -1
View File
@@ -24,6 +24,7 @@ mod parse_error_pedagogy;
mod project_lifecycle;
mod replay_command;
mod seed;
mod show_list;
mod sql_alter_table;
mod sql_create_index;
mod sql_create_table;
@@ -34,6 +35,5 @@ mod sql_drop_table;
mod sql_insert;
mod sql_select;
mod sql_update;
mod show_list;
mod undo_snapshots;
mod walking_skeleton;
+457 -83
View File
@@ -54,10 +54,7 @@ fn error_lines_for(input: &str) -> Vec<String> {
}
fn dump(input: &str, lines: &[String]) -> String {
format!(
"INPUT: {input:?}\nERROR LINES:\n{}",
lines.join("\n"),
)
format!("INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"),)
}
/// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a
@@ -71,57 +68,228 @@ fn near_miss_matrix_simple_mode() {
// app-lifecycle arg errors. The arg-less commands all reject
// trailing junk with "expected end of input" + their usage
// (audited 2026-06-05); locked here as regression insurance.
("quit now", &["after `quit`, expected end of input", " quit"]),
(
"quit now",
&["after `quit`, expected end of input", " quit"],
),
// `help` now takes an optional single-word topic (H3), so
// `help foo` parses (topic lookup); only a *multi-word*
// topic is the near-miss that rejects trailing junk.
("help foo bar", &["after `help foo`, expected end of input", "help [<command>]"]),
("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]),
(
"help foo bar",
&[
"after `help foo`, expected end of input",
"help [<command>]",
],
),
(
"rebuild now",
&["after `rebuild`, expected end of input", " rebuild"],
),
("new foo", &["after `new`, expected end of input", " new"]),
("load foo", &["after `load`, expected end of input", " load"]),
("undo foo", &["after `undo`, expected end of input", " undo"]),
("redo foo", &["after `redo`, expected end of input", " redo"]),
("export foo bar", &["after `export foo`, expected end of input", "export [<path>]"]),
("import a b c", &["after `import a`, expected end of input", "import <zip-path>"]),
("save sideways", &["after `save`, expected end of input", "save | save as"]),
("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]),
("messages louder", &["unknown messages mode 'louder'", "messages short"]),
("copy everything", &["unknown copy target 'everything'", "copy all"]),
(
"load foo",
&["after `load`, expected end of input", " load"],
),
(
"undo foo",
&["after `undo`, expected end of input", " undo"],
),
(
"redo foo",
&["after `redo`, expected end of input", " redo"],
),
(
"export foo bar",
&[
"after `export foo`, expected end of input",
"export [<path>]",
],
),
(
"import a b c",
&[
"after `import a`, expected end of input",
"import <zip-path>",
],
),
(
"save sideways",
&["after `save`, expected end of input", "save | save as"],
),
(
"mode sideways",
&["unknown mode 'sideways'", "mode simple | mode advanced"],
),
(
"messages louder",
&["unknown messages mode 'louder'", "messages short"],
),
(
"copy everything",
&["unknown copy target 'everything'", "copy all"],
),
// DDL bare + missing-slot
("create", &["after `create`, expected `table`", "create table <Name> with pk"]),
("create table", &["after `create table`, expected identifier", "create table <Name> with pk"]),
("create table T", &["with pk", "create table <Name> with pk"]),
(
"create",
&[
"after `create`, expected `table`",
"create table <Name> with pk",
],
),
(
"create table",
&[
"after `create table`, expected identifier",
"create table <Name> with pk",
],
),
(
"create table T",
&["with pk", "create table <Name> with pk"],
),
// G1: relationship cardinality reads as the named construct.
("add", &["after `add`, expected `column`, `1:n relationship`", "add 1:n relationship"]),
("drop table", &["after `drop table`, expected table name", "drop table <Name>"]),
("add column", &["after `add column`, expected table name", "add column [to] [table]"]),
("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]),
("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]),
("change", &["after `change`, expected `column`", "change column [in] [table]"]),
("change column", &["after `change column`, expected table name", "change column [in] [table]"]),
(
"add",
&[
"after `add`, expected `column`, `1:n relationship`",
"add 1:n relationship",
],
),
(
"drop table",
&[
"after `drop table`, expected table name",
"drop table <Name>",
],
),
(
"add column",
&[
"after `add column`, expected table name",
"add column [to] [table]",
],
),
(
"rename",
&[
"after `rename`, expected `column`",
"rename column [in] [table]",
],
),
(
"rename column",
&[
"after `rename column`, expected table name",
"rename column [in] [table]",
],
),
(
"change",
&[
"after `change`, expected `column`",
"change column [in] [table]",
],
),
(
"change column",
&[
"after `change column`, expected table name",
"change column [in] [table]",
],
),
// data bare + missing-clause
("insert", &["after `insert`, expected `into`", "insert into <Table>"]),
("insert into", &["after `insert into`, expected table name", "insert into <Table>"]),
("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into <Table>"]),
("update", &["after `update`, expected table name", "update <Table> set"]),
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
("update T set x=1", &["expected `where` or `--all-rows`", "update <Table> set"]),
("delete", &["after `delete`, expected `from`", "delete from <Table>"]),
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
("delete from T", &["expected `where` or `--all-rows`", "delete from <Table>"]),
("seed", &["after `seed`, expected table name", "seed <Table> [count]"]),
(
"insert",
&["after `insert`, expected `into`", "insert into <Table>"],
),
(
"insert into",
&[
"after `insert into`, expected table name",
"insert into <Table>",
],
),
(
"insert into T",
&[
"after `insert into T`, expected `values` or `(`",
"insert into <Table>",
],
),
(
"update",
&["after `update`, expected table name", "update <Table> set"],
),
(
"update T",
&["after `update T`, expected `set`", "update <Table> set"],
),
(
"update T set x=1",
&["expected `where` or `--all-rows`", "update <Table> set"],
),
(
"delete",
&["after `delete`, expected `from`", "delete from <Table>"],
),
(
"delete from",
&[
"after `delete from`, expected table name",
"delete from <Table>",
],
),
(
"delete from T",
&["expected `where` or `--all-rows`", "delete from <Table>"],
),
(
"seed",
&["after `seed`, expected table name", "seed <Table> [count]"],
),
// Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill.
("seed T set", &["after `seed T set`, expected column name", "seed <Table>.<col>"]),
(
"seed T set",
&[
"after `seed T set`, expected column name",
"seed <Table>.<col>",
],
),
(
"seed T set role",
&["after `seed T set role`, expected `=`, `in`, `between`, or `as`", "seed <Table>.<col>"],
&[
"after `seed T set role`, expected `=`, `in`, `between`, or `as`",
"seed <Table>.<col>",
],
),
(
"seed T.",
&[
"after `seed T.`, expected column name",
"seed <Table>.<col>",
],
),
(
"replay",
&[
"after `replay`, expected string literal or path",
"replay <path>",
],
),
(
"explain",
&[
"after `explain`, expected `show`, `update`, or `delete`",
"explain show data",
],
),
("seed T.", &["after `seed T.`, expected column name", "seed <Table>.<col>"]),
("replay", &["after `replay`, expected string literal or path", "replay <path>"]),
("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]),
// advanced-only entry word typed in simple mode → "this is SQL" rail
("select * from T", &["`select` is SQL", "mode advanced"]),
("alter table T add column c int", &["`alter` is SQL", "mode advanced"]),
(
"alter table T add column c int",
&["`alter` is SQL", "mode advanced"],
),
];
for (input, needles) in matrix {
let lines = error_lines_for(input);
@@ -164,26 +332,160 @@ fn near_miss_matrix_committed_multiforms() {
// (input, advanced?, required-substrings)
let matrix: &[(&str, bool, &[&str])] = &[
// add / drop multi-forms (simple)
("add index", false, &["after `add index`, expected `on` or `as`", "add index [as <Name>] on"]),
("add index on T", false, &["after `add index on T`, expected `(`", "add index [as <Name>] on"]),
("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]),
("add constraint not null", false, &["after `add constraint not null`, expected `to`", "add constraint not null to"]),
("add 1:n relationship", false, &["after `add 1:n relationship`, expected `from` or `as`", "add 1:n relationship"]),
("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from <Parent>.<col>"]),
("drop constraint", false, &["after `drop constraint`, expected `not`, `unique`, `default`, or `check`", "drop constraint (not null"]),
("drop constraint not null", false, &["after `drop constraint not null`, expected `from`", "drop constraint (not null"]),
("drop index", false, &["after `drop index`, expected `on` or index name", "drop index <Name>", "drop index on <Table>"]),
("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on <Table>"]),
("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship <Name>"]),
("show table", false, &["after `show table`, expected table name", "show table <Table>"]),
("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship <name>"]),
("show index", false, &["after `show index`, expected index name", "show index <name>"]),
("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]),
(
"add index",
false,
&[
"after `add index`, expected `on` or `as`",
"add index [as <Name>] on",
],
),
(
"add index on T",
false,
&[
"after `add index on T`, expected `(`",
"add index [as <Name>] on",
],
),
(
"add constraint",
false,
&[
"after `add constraint`, expected `not`, `unique`, `default`, or `check`",
"add constraint not null to",
],
),
(
"add constraint not null",
false,
&[
"after `add constraint not null`, expected `to`",
"add constraint not null to",
],
),
(
"add 1:n relationship",
false,
&[
"after `add 1:n relationship`, expected `from` or `as`",
"add 1:n relationship",
],
),
(
"add 1:n relationship from",
false,
&[
"after `add 1:n relationship from`, expected table name",
"from <Parent>.<col>",
],
),
(
"drop constraint",
false,
&[
"after `drop constraint`, expected `not`, `unique`, `default`, or `check`",
"drop constraint (not null",
],
),
(
"drop constraint not null",
false,
&[
"after `drop constraint not null`, expected `from`",
"drop constraint (not null",
],
),
(
"drop index",
false,
&[
"after `drop index`, expected `on` or index name",
"drop index <Name>",
"drop index on <Table>",
],
),
(
"drop index on T",
false,
&[
"after `drop index on T`, expected `(`",
"drop index on <Table>",
],
),
(
"drop relationship",
false,
&[
"after `drop relationship`, expected `from` or relationship name",
"drop relationship <Name>",
],
),
(
"show table",
false,
&[
"after `show table`, expected table name",
"show table <Table>",
],
),
(
"show relationship",
false,
&[
"after `show relationship`, expected relationship name",
"show relationship <name>",
],
),
(
"show index",
false,
&[
"after `show index`, expected index name",
"show index <name>",
],
),
(
"change column in table T: c",
false,
&[
"after `change column in table T: c`, expected `(`",
"change column [in] [table]",
],
),
// advanced committed multi-forms
("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]),
("create unique index", true, &["after `create unique index`, expected `on`, identifier, or `if`", "create [unique] index"]),
("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table <Table> add column"]),
("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table <Table> drop column"]),
(
"create index on",
true,
&[
"after `create index on`, expected table name",
"create [unique] index",
],
),
(
"create unique index",
true,
&[
"after `create unique index`, expected `on`, identifier, or `if`",
"create [unique] index",
],
),
(
"alter table T add",
true,
&[
"after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`",
"alter table <Table> add column",
],
),
(
"alter table T drop",
true,
&[
"after `alter table T drop`, expected `column` or `constraint`",
"alter table <Table> drop column",
],
),
];
for (input, advanced, needles) in matrix {
let lines = if *advanced {
@@ -265,7 +567,10 @@ fn advanced_mode_usage_block_shows_sql_and_dsl_forms() {
// (mode-primary first).
let sql_at = joined.find("create table [if not exists]").unwrap();
let dsl_at = joined.find("create table <Name> with pk").unwrap();
assert!(sql_at < dsl_at, "SQL form should precede the DSL form\n{dump_msg}");
assert!(
sql_at < dsl_at,
"SQL form should precede the DSL form\n{dump_msg}"
);
}
#[test]
@@ -307,23 +612,94 @@ fn advanced_cross_join_with_on_teaches_no_on_clause() {
fn near_miss_matrix_advanced_mode() {
let matrix: &[(&str, &[&str])] = &[
// SQL select / with (G2, G4)
("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]),
("select * from", &["after `select * from`, expected table name", "select (* |"]),
("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]),
(
"select",
&[
"expected a projection: `*`, a column, or an expression",
"select (* |",
],
),
(
"select * from",
&["after `select * from`, expected table name", "select (* |"],
),
(
"with",
&[
"after `with`, expected identifier or `recursive`",
"with [recursive]",
"as (",
],
),
// create / drop / alter — SQL forms AND the still-valid DSL
// fallback forms, SQL-primary first (G3).
("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index", "create table <Name> with pk"]),
("create table", &["after `create table`, expected identifier or `if`", "create table [if not exists]"]),
("create index", &["after `create index`, expected `on`", "create [unique] index"]),
("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]),
("alter", &["after `alter`, expected `table`", "alter table <Table> add column"]),
("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table <Table>"]),
(
"create",
&[
"after `create`, expected `table`",
"create table [if not exists]",
"create [unique] index",
"create table <Name> with pk",
],
),
(
"create table",
&[
"after `create table`, expected identifier or `if`",
"create table [if not exists]",
],
),
(
"create index",
&[
"after `create index`, expected `on`",
"create [unique] index",
],
),
(
"drop",
&[
"after `drop`, expected `table`",
"drop table [if exists]",
"drop column [from]",
"drop relationship",
],
),
(
"alter",
&[
"after `alter`, expected `table`",
"alter table <Table> add column",
],
),
(
"alter table T",
&[
"expected `add`, `drop`, `rename`, or `alter`",
"alter table <Table>",
],
),
// shared insert/update/delete — must show usage, not the
// available-commands fallback (regression guard for the
// empty-usage_ids SQL nodes).
("insert into T", &["after `insert into T`, expected `values`, `with`, `select`, or `(`", "insert into <Table>"]),
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
(
"insert into T",
&[
"after `insert into T`, expected `values`, `with`, `select`, or `(`",
"insert into <Table>",
],
),
(
"update T",
&["after `update T`, expected `set`", "update <Table> set"],
),
(
"delete from",
&[
"after `delete from`, expected table name",
"delete from <Table>",
],
),
];
for (input, needles) in matrix {
let lines = advanced_error_lines_for(input);
@@ -365,7 +741,9 @@ fn with_alone_renders_cte_usage_not_select() {
.collect();
let dump_msg = dump("with", &lines);
assert!(
lines.iter().any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
lines
.iter()
.any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
"missing CTE-specific `with … as (…)` usage template\n{dump_msg}",
);
}
@@ -383,7 +761,9 @@ fn create_alone_renders_create_table_usage() {
"missing usage: header\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("create table") && l.contains("with pk")),
lines
.iter()
.any(|l| l.contains("create table") && l.contains("with pk")),
"missing create_table usage template\n{dump_msg}",
);
}
@@ -464,8 +844,7 @@ fn unknown_command_falls_back_to_available_commands_list() {
.unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
// The list must include all ten command-entry keywords.
for cmd in [
"add", "change", "create", "delete", "drop", "insert",
"rename", "replay", "show", "update",
"add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update",
] {
assert!(
available.contains(&format!("`{cmd}`")),
@@ -543,8 +922,3 @@ fn caret_aligns_under_offending_token() {
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
);
}
+23 -10
View File
@@ -22,14 +22,16 @@ fn tempdir() -> tempfile::TempDir {
#[test]
fn no_args_creates_temp_project_under_data_root() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path()))
.expect("open_or_create with empty CLI");
let project =
project::open_or_create(None, Some(data.path())).expect("open_or_create with empty CLI");
let path = project.path();
assert!(path.exists(), "project dir should exist");
assert!(path.starts_with(data.path()));
assert_eq!(
path.parent().and_then(|p| p.file_name()).map(|s| s.to_string_lossy().into_owned()),
path.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned()),
Some(PROJECTS_SUBDIR.to_string()),
);
@@ -96,8 +98,7 @@ fn positional_path_opens_existing_project() {
// Now drive open_or_create with the path as if it were a
// CLI positional argument.
let project = project::open_or_create(Some(&path), None)
.expect("open via positional path");
let project = project::open_or_create(Some(&path), None).expect("open via positional path");
assert_eq!(project.path(), path);
}
@@ -142,7 +143,10 @@ fn data_dir_override_does_not_touch_default_os_dir() {
assert!(p1_path.starts_with(data.path()));
assert!(p2_path.starts_with(data.path()));
assert_ne!(p1_path, p2_path, "two temp projects must have distinct names");
assert_ne!(
p1_path, p2_path,
"two temp projects must have distinct names"
);
}
#[test]
@@ -167,11 +171,18 @@ fn db_persists_across_open_close_cycles() {
db.create_table(
"Customers".to_string(),
vec![
rdbms_playground::dsl::ColumnSpec::new("id".to_string(), rdbms_playground::dsl::Type::Serial),
rdbms_playground::dsl::ColumnSpec::new("Name".to_string(), rdbms_playground::dsl::Type::Text),
rdbms_playground::dsl::ColumnSpec::new(
"id".to_string(),
rdbms_playground::dsl::Type::Serial,
),
rdbms_playground::dsl::ColumnSpec::new(
"Name".to_string(),
rdbms_playground::dsl::Type::Text,
),
],
vec!["id".to_string()],
None)
None,
)
.await
.expect("create_table");
});
@@ -187,7 +198,9 @@ fn db_persists_across_open_close_cycles() {
.enable_all()
.build()
.unwrap();
let tables = rt.block_on(async { db.list_tables().await }).expect("list_tables");
let tables = rt
.block_on(async { db.list_tables().await })
.expect("list_tables");
assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}");
// Sanity: the project.yaml and history.log are still empty
+61 -52
View File
@@ -47,8 +47,7 @@ fn tempdir() -> tempfile::TempDir {
/// harness — most tests only need to write a script file and
/// call `run_replay`.
fn open_project_db(data_root: &Path) -> (project::Project, Database) {
let project = project::open_or_create(None, Some(data_root))
.expect("open_or_create");
let project = project::open_or_create(None, Some(data_root)).expect("open_or_create");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
@@ -132,9 +131,7 @@ fn replay_three_lines_dispatches_three_commands() {
insert into T (1, 'Alice')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
assert_completed(&events, 3);
// The dispatched commands actually mutated state.
@@ -167,8 +164,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
2026-05-24T10:00:03Z|ok|insert into T (id, v) values (1, 'alpha')\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three `ok` records replayed; the `err` record is skipped (not
// counted, not a failure).
assert_completed(&events, 3);
@@ -215,14 +211,21 @@ fn replay_skips_app_lifecycle_commands_silently() {
2026-05-24T10:00:13Z|ok|add column T: v (text)\n\
2026-05-24T10:00:14Z|ok|insert into T (id, v) values (1, 'alpha')\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
// Three data/schema commands ran; every app-lifecycle line was
// skipped silently (no panic, no abort, no warnings, no quit).
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
assert_eq!(*count, 3, "only the 3 write commands ran; events: {events:?}");
assert!(warnings.is_empty(), "these skips are silent; got {warnings:?}");
AppEvent::ReplayCompleted {
count, warnings, ..
} => {
assert_eq!(
*count, 3,
"only the 3 write commands ran; events: {events:?}"
);
assert!(
warnings.is_empty(),
"these skips are silent; got {warnings:?}"
);
}
other => panic!("expected ReplayCompleted, got {other:?}"),
}
@@ -251,10 +254,11 @@ fn replay_skips_import_with_a_warning() {
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)\n\
2026-05-24T10:00:01Z|ok|import shared.zip as Imported\n",
);
let events =
rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
let events = rt().block_on(async { run_replay(&db, project.path(), "history.log").await });
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
AppEvent::ReplayCompleted {
count, warnings, ..
} => {
assert_eq!(*count, 1, "only the create ran; events: {events:?}");
assert!(
warnings.iter().any(|w| w.contains("import shared.zip")),
@@ -282,9 +286,7 @@ fn replay_skips_blank_lines_and_comments() {
\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
// Only two non-blank, non-comment lines.
assert_completed(&events, 2);
}
@@ -295,9 +297,7 @@ fn replay_empty_file_completes_with_zero_commands() {
let (project, db) = open_project_db(data.path());
write_script(project.path(), "empty.commands", "");
let events = rt().block_on(async {
run_replay(&db, project.path(), "empty.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "empty.commands").await });
assert_completed(&events, 0);
}
@@ -311,9 +311,8 @@ fn replay_only_comments_completes_with_zero_commands() {
"# just\n# comments\n\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "comments.commands").await
});
let events =
rt().block_on(async { run_replay(&db, project.path(), "comments.commands").await });
assert_completed(&events, 0);
}
@@ -350,8 +349,14 @@ fn replay_constraint_failure_shows_real_names_not_placeholders() {
// INSERT command — the **real offending value** is shown too (it used
// to degrade to the neutral "that value" because `SqlInsert` discarded
// its literals).
assert!(error.contains("T.email"), "names the real table.column; got: {error}");
assert!(error.contains("a@b.com"), "shows the real offending value; got: {error}");
assert!(
error.contains("T.email"),
"names the real table.column; got: {error}"
);
assert!(
error.contains("a@b.com"),
"shows the real offending value; got: {error}"
);
}
#[test]
@@ -359,9 +364,8 @@ fn replay_missing_file_fails_with_line_number_zero() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
let events = rt().block_on(async {
run_replay(&db, project.path(), "no-such-file.commands").await
});
let events =
rt().block_on(async { run_replay(&db, project.path(), "no-such-file.commands").await });
let failed = assert_failed_at(&events, 0);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
@@ -387,9 +391,7 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
insert into T (1, 'should not happen')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, command, .. } = failed else {
unreachable!()
@@ -452,9 +454,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
insert into T values (1, 'not a number')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "typed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "typed.commands").await });
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
@@ -489,9 +489,7 @@ fn replay_aborts_on_first_runtime_failure_and_reports_line() {
insert into T (1)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "bad.commands").await });
let _ = assert_failed_at(&events, 2);
}
@@ -504,23 +502,29 @@ fn replay_skips_nested_replay_with_a_warning() {
// because the nested file's commands are not reconstructed.
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
write_script(
project.path(),
"inner.commands",
"create table T with pk id(int)\n",
);
write_script(
project.path(),
"outer.commands",
"create table U with pk id(int)\nreplay inner.commands\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "outer.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "outer.commands").await });
// The outer `create table U` ran; the nested `replay` was
// skipped (count 1), with a warning.
match events.last().expect("event") {
AppEvent::ReplayCompleted { count, warnings, .. } => {
AppEvent::ReplayCompleted {
count, warnings, ..
} => {
assert_eq!(*count, 1, "only the outer create ran; events: {events:?}");
assert!(
warnings.iter().any(|w| w.contains("nested") && w.contains("replay inner.commands")),
warnings
.iter()
.any(|w| w.contains("nested") && w.contains("replay inner.commands")),
"expected a nested-replay skip warning; got {warnings:?}",
);
}
@@ -528,7 +532,10 @@ fn replay_skips_nested_replay_with_a_warning() {
}
// The nested file's table was NOT created (the replay was skipped).
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await });
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
assert!(
cols.is_err(),
"inner.commands' table T must not exist (nested replay skipped)"
);
}
#[test]
@@ -546,20 +553,22 @@ fn replay_history_log_records_subcommands_only() {
"create table T with pk id(int)\nadd column T: name (text)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
let events = rt().block_on(async { run_replay(&db, project.path(), "seed.commands").await });
assert_completed(&events, 2);
let history = fs::read_to_string(project.path().join("history.log"))
.expect("history.log exists");
let history =
fs::read_to_string(project.path().join("history.log")).expect("history.log exists");
// Per-command entries landed.
assert!(
history.lines().any(|l| l.contains("create table T with pk id(int)")),
history
.lines()
.any(|l| l.contains("create table T with pk id(int)")),
"history.log missing create line:\n{history}"
);
assert!(
history.lines().any(|l| l.contains("add column T: name (text)")),
history
.lines()
.any(|l| l.contains("add column T: name (text)")),
"history.log missing add column line:\n{history}"
);
// The replay invocation itself did NOT land — that's
+376 -93
View File
@@ -18,8 +18,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
@@ -76,7 +75,10 @@ fn seed_parses_with_and_without_count() {
match parse_command("seed People").expect("`seed People` parses") {
Command::Seed { table, count, .. } => {
assert_eq!(table, "People");
assert_eq!(count, None, "omitted count is None (executor defaults to 20)");
assert_eq!(
count, None,
"omitted count is None (executor defaults to 20)"
);
}
other => panic!("expected Command::Seed, got {other:?}"),
}
@@ -134,7 +136,10 @@ fn seed_set_fixed_value_override_parses() {
let (_t, ov) = seed_overrides("seed users 5 set status = 'active'");
assert_eq!(ov.len(), 1);
assert_eq!(ov[0].column, "status");
assert_eq!(ov[0].kind, SeedOverrideKind::Fixed(Value::Text("active".into())));
assert_eq!(
ov[0].kind,
SeedOverrideKind::Fixed(Value::Text("active".into()))
);
}
#[test]
@@ -177,8 +182,7 @@ fn seed_set_numeric_range_override_parses() {
#[test]
fn seed_set_date_range_override_parses_with_quoted_dates() {
// ADR-0048 D2 amendment: dates in the range form are quoted strings.
let (_t, ov) =
seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
let (_t, ov) = seed_overrides("seed users set signup between '2023-01-01' and '2024-12-31'");
assert_eq!(
ov[0].kind,
SeedOverrideKind::Range {
@@ -207,7 +211,9 @@ fn seed_count_is_not_confused_by_a_range_value() {
// No positional count, but `between 18 and 80` carries NumberLits —
// they must not be read as the count (bounded to before `set`).
match parse_command("seed users set age between 18 and 80").expect("parses") {
Command::Seed { count, overrides, .. } => {
Command::Seed {
count, overrides, ..
} => {
assert_eq!(count, None, "the count is None, not 18");
assert_eq!(overrides.len(), 1);
}
@@ -267,7 +273,14 @@ fn seed_populates_a_table_and_persists_rows() {
create_people(&db, &rt);
let result = rt
.block_on(db.seed("People".into(), None, Some(7), Vec::new(), Some(42), Some("seed People 7".into())))
.block_on(db.seed(
"People".into(),
None,
Some(7),
Vec::new(),
Some(42),
Some("seed People 7".into()),
))
.expect("seed succeeds");
assert_eq!(result.produced, 7);
@@ -278,22 +291,34 @@ fn seed_populates_a_table_and_persists_rows() {
"CSV should hold 7 generated rows:\n{csv}"
);
// The generated `email` column produces address-shaped values.
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
assert!(
csv.contains('@'),
"seeded emails should appear in the CSV:\n{csv}"
);
}
/// Parse a seeded table's CSV into per-column value lists (simple
/// comma-split — the values under test carry no commas/quotes).
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
let header: Vec<String> = lines.next().unwrap().split(',').map(str::to_string).collect();
let rows: Vec<Vec<String>> =
lines.map(|l| l.split(',').map(str::to_string).collect()).collect();
let header: Vec<String> = lines
.next()
.unwrap()
.split(',')
.map(str::to_string)
.collect();
let rows: Vec<Vec<String>> = lines
.map(|l| l.split(',').map(str::to_string).collect())
.collect();
(header, rows)
}
fn column_values(csv: &str, col: &str) -> Vec<String> {
let (header, rows) = csv_columns(csv);
let idx = header.iter().position(|h| h == col).expect("column present");
let idx = header
.iter()
.position(|h| h == col)
.expect("column present");
rows.iter().map(|r| r[idx].clone()).collect()
}
@@ -321,20 +346,36 @@ fn seed_year_and_choice_set_heuristics() {
))
.expect("create Records");
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into())))
.expect("seed succeeds");
rt.block_on(db.seed(
"Records".into(),
None,
Some(30),
Vec::new(),
Some(99),
Some("seed Records 30".into()),
))
.expect("seed succeeds");
let csv = read_csv(&project, "Records").expect("Records CSV exists");
for y in column_values(&csv, "birth_year") {
let n: i32 = y.parse().expect("birth_year is an int");
assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year");
assert!(
(1945..=2007).contains(&n),
"birth_year {n} must be a plausible birth year"
);
}
for y in column_values(&csv, "published") {
let n: i32 = y.parse().expect("published is an int");
assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year");
assert!(
(1950..=2025).contains(&n),
"published {n} must be a plausible recent year"
);
}
for p in column_values(&csv, "priority") {
assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high");
assert!(
["low", "medium", "high"].contains(&p.as_str()),
"priority `{p}` must be low/medium/high"
);
}
for s in column_values(&csv, "severity") {
assert!(
@@ -405,7 +446,14 @@ fn seed_count_defaults_to_twenty() {
create_people(&db, &rt);
let result = rt
.block_on(db.seed("People".into(), None, None, Vec::new(), Some(1), Some("seed People".into())))
.block_on(db.seed(
"People".into(),
None,
None,
Vec::new(),
Some(1),
Some("seed People".into()),
))
.expect("seed succeeds");
assert_eq!(result.produced, 20, "omitted count defaults to 20");
let csv = read_csv(&project, "People").expect("People CSV exists");
@@ -420,10 +468,24 @@ fn seed_is_reproducible_with_a_fixed_seed() {
create_people(&db1, &rt);
create_people(&db2, &rt);
rt.block_on(db1.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
.expect("seed run 1");
rt.block_on(db2.seed("People".into(), None, Some(4), Vec::new(), Some(123), Some("seed People 4".into())))
.expect("seed run 2");
rt.block_on(db1.seed(
"People".into(),
None,
Some(4),
Vec::new(),
Some(123),
Some("seed People 4".into()),
))
.expect("seed run 1");
rt.block_on(db2.seed(
"People".into(),
None,
Some(4),
Vec::new(),
Some(123),
Some("seed People 4".into()),
))
.expect("seed run 2");
let csv1 = read_csv(&p1, "People").expect("csv 1");
let csv2 = read_csv(&p2, "People").expect("csv 2");
@@ -493,10 +555,24 @@ fn seed_fills_foreign_keys_from_existing_parents() {
create_users_and_orders(&db, &rt, true);
// 5 parents → serial ids 1..=5.
rt.block_on(db.seed("Users".into(), None, Some(5), Vec::new(), Some(1), Some("seed Users 5".into())))
.expect("seed Users");
rt.block_on(db.seed(
"Users".into(),
None,
Some(5),
Vec::new(),
Some(1),
Some("seed Users 5".into()),
))
.expect("seed Users");
let res = rt
.block_on(db.seed("Orders".into(), None, Some(10), Vec::new(), Some(2), Some("seed Orders 10".into())))
.block_on(db.seed(
"Orders".into(),
None,
Some(10),
Vec::new(),
Some(2),
Some("seed Orders 10".into()),
))
.expect("seed Orders");
assert_eq!(res.produced, 10, "every child row must insert (valid FK)");
@@ -520,10 +596,20 @@ fn seed_refuses_when_a_parent_table_is_empty() {
// Users is empty — no valid FK can be fabricated.
let err = rt
.block_on(db.seed("Orders".into(), None, Some(3), Vec::new(), Some(1), Some("seed Orders 3".into())))
.block_on(db.seed(
"Orders".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Orders 3".into()),
))
.expect_err("seed must refuse an empty parent");
let msg = err.to_string();
assert!(msg.contains("Users"), "error should name the empty parent: {msg}");
assert!(
msg.contains("Users"),
"error should name the empty parent: {msg}"
);
let lower = msg.to_lowercase();
assert!(
lower.contains("no rows") || lower.contains("first"),
@@ -546,7 +632,14 @@ fn seed_refuses_a_not_null_blob_column() {
.expect("create Files");
let err = rt
.block_on(db.seed("Files".into(), None, Some(2), Vec::new(), Some(1), Some("seed Files 2".into())))
.block_on(db.seed(
"Files".into(),
None,
Some(2),
Vec::new(),
Some(1),
Some("seed Files 2".into()),
))
.expect_err("seed must refuse a NOT NULL blob");
let msg = err.to_string();
assert!(
@@ -573,7 +666,14 @@ fn seed_omits_a_nullable_blob_column() {
.expect("create Files");
let res = rt
.block_on(db.seed("Files".into(), None, Some(3), Vec::new(), Some(1), Some("seed Files 3".into())))
.block_on(db.seed(
"Files".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Files 3".into()),
))
.expect("seed succeeds despite the nullable blob");
assert_eq!(res.produced, 3);
let csv = read_csv(&project, "Files").expect("Files CSV");
@@ -607,14 +707,25 @@ fn seed_keeps_unique_columns_distinct() {
.expect("create Tags");
let res = rt
.block_on(db.seed("Tags".into(), None, Some(8), Vec::new(), Some(3), Some("seed Tags 8".into())))
.block_on(db.seed(
"Tags".into(),
None,
Some(8),
Vec::new(),
Some(3),
Some("seed Tags 8".into()),
))
.expect("seed");
assert_eq!(res.produced, 8);
let csv = read_csv(&project, "Tags").expect("Tags CSV");
let labels = nth_column_values(&csv, 1);
let distinct: std::collections::HashSet<&String> = labels.iter().collect();
assert_eq!(distinct.len(), labels.len(), "UNIQUE column has duplicates:\n{csv}");
assert_eq!(
distinct.len(),
labels.len(),
"UNIQUE column has duplicates:\n{csv}"
);
}
#[test]
@@ -636,7 +747,14 @@ fn seed_sequences_identifier_int_columns() {
.expect("create Items");
let res = rt
.block_on(db.seed("Items".into(), None, Some(5), Vec::new(), Some(1), Some("seed Items 5".into())))
.block_on(db.seed(
"Items".into(),
None,
Some(5),
Vec::new(),
Some(1),
Some("seed Items 5".into()),
))
.expect("seed");
assert_eq!(res.produced, 5);
@@ -646,7 +764,11 @@ fn seed_sequences_identifier_int_columns() {
.map(|s| s.parse().expect("code is an int"))
.collect();
let distinct: std::collections::HashSet<i64> = codes.iter().copied().collect();
assert_eq!(distinct.len(), 5, "identifier ints must be unique: {codes:?}");
assert_eq!(
distinct.len(),
5,
"identifier ints must be unique: {codes:?}"
);
}
#[test]
@@ -667,14 +789,24 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
)
.await
.expect("create parent");
db.seed(t.into(), None, Some(2), Vec::new(), Some(1), Some(format!("seed {t} 2")))
.await
.expect("seed parent");
db.seed(
t.into(),
None,
Some(2),
Vec::new(),
Some(1),
Some(format!("seed {t} 2")),
)
.await
.expect("seed parent");
}
// Junction with a compound PK over its two FK columns.
db.create_table(
"J".to_string(),
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
],
vec!["a".to_string(), "b".to_string()],
None,
)
@@ -709,11 +841,21 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
// Requesting 10 caps at the 4 available distinct combinations.
let res = db
.seed("J".into(), None, Some(10), Vec::new(), Some(7), Some("seed J 10".into()))
.seed(
"J".into(),
None,
Some(10),
Vec::new(),
Some(7),
Some("seed J 10".into()),
)
.await
.expect("seed J");
assert_eq!(res.produced, 4, "junction caps at available combos");
assert_eq!(res.requested, 10, "the requested count is reported for the cap note");
assert_eq!(
res.requested, 10,
"the requested count is reported for the cap note"
);
});
let csv = read_csv(&project, "J").expect("J CSV");
@@ -724,7 +866,11 @@ fn seed_junction_produces_distinct_combinations_and_caps() {
.map(str::to_string)
.collect();
let distinct: std::collections::HashSet<&String> = pairs.iter().collect();
assert_eq!(distinct.len(), pairs.len(), "junction rows must be distinct:\n{csv}");
assert_eq!(
distinct.len(),
pairs.len(),
"junction rows must be distinct:\n{csv}"
);
}
#[test]
@@ -743,9 +889,19 @@ fn seed_draws_enum_values_from_an_in_check() {
// Every generated status must satisfy the CHECK, so all rows insert.
let res = rt
.block_on(db.seed("Tickets".into(), None, Some(12), Vec::new(), Some(2), Some("seed Tickets 12".into())))
.block_on(db.seed(
"Tickets".into(),
None,
Some(12),
Vec::new(),
Some(2),
Some("seed Tickets 12".into()),
))
.expect("seed");
assert_eq!(res.produced, 12, "all rows insert — values satisfy the CHECK");
assert_eq!(
res.produced, 12,
"all rows insert — values satisfy the CHECK"
);
let csv = read_csv(&project, "Tickets").expect("Tickets CSV");
for v in nth_column_values(&csv, 1) {
@@ -780,7 +936,14 @@ fn seed_advises_on_enum_ish_columns() {
.expect("create Tasks");
let res = rt
.block_on(db.seed("Tasks".into(), None, Some(3), Vec::new(), Some(1), Some("seed Tasks 3".into())))
.block_on(db.seed(
"Tasks".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Tasks 3".into()),
))
.expect("seed");
assert!(
res.advisory_columns.contains(&"status".to_string()),
@@ -795,7 +958,14 @@ fn seed_refuses_an_excessive_count() {
let rt = rt();
create_people(&db, &rt);
let err = rt
.block_on(db.seed("People".into(), None, Some(1_000_000), Vec::new(), Some(1), Some("seed People 1000000".into())))
.block_on(db.seed(
"People".into(),
None,
Some(1_000_000),
Vec::new(),
Some(1),
Some("seed People 1000000".into()),
))
.expect_err("an excessive count must be refused");
assert!(
err.to_string().to_lowercase().contains("maximum"),
@@ -810,7 +980,14 @@ fn seed_preview_is_capped_but_count_is_full() {
create_people(&db, &rt);
let res = rt
.block_on(db.seed("People".into(), None, Some(25), Vec::new(), Some(1), Some("seed People 25".into())))
.block_on(db.seed(
"People".into(),
None,
Some(25),
Vec::new(),
Some(1),
Some("seed People 25".into()),
))
.expect("seed");
assert_eq!(res.produced, 25, "the full count is produced");
assert_eq!(res.data.rows.len(), 20, "the preview is capped at 20 rows");
@@ -860,14 +1037,24 @@ fn seed_is_one_undo_step() {
.expect("open db with undo");
let rt = rt();
create_people(&db, &rt);
rt.block_on(db.seed("People".into(), None, Some(6), Vec::new(), Some(1), Some("seed People 6".into())))
.expect("seed");
rt.block_on(db.seed(
"People".into(),
None,
Some(6),
Vec::new(),
Some(1),
Some("seed People 6".into()),
))
.expect("seed");
assert_eq!(data_row_count(&read_csv(&project, "People").unwrap()), 6);
// One undo removes the whole seed batch (ADR-0048 D15).
rt.block_on(db.undo()).unwrap().expect("undo applied");
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
assert_eq!(rows, 0, "one undo must remove every seeded row in a single step");
assert_eq!(
rows, 0,
"one undo must remove every seeded row in a single step"
);
}
#[test]
@@ -882,10 +1069,17 @@ fn seed_column_fill_is_one_undo_step() {
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 5 --seed 1").expect("seed");
// Fill `status` across all 5 rows with a constant, then undo once.
run_seed(&db, &rt, "seed Members.status set status = 'flagged' --seed 2")
.expect("column-fill");
run_seed(
&db,
&rt,
"seed Members.status set status = 'flagged' --seed 2",
)
.expect("column-fill");
let before = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
assert!(before.iter().all(|s| s == "flagged"), "all rows filled: {before:?}");
assert!(
before.iter().all(|s| s == "flagged"),
"all rows filled: {before:?}"
);
rt.block_on(db.undo()).unwrap().expect("undo applied");
let after = named_column_values(&read_csv(&project, "Members").unwrap(), "status");
@@ -893,7 +1087,11 @@ fn seed_column_fill_is_one_undo_step() {
after.iter().all(|s| s != "flagged"),
"one undo reverts the whole column-fill in a single step: {after:?}"
);
assert_eq!(after.len(), 5, "undo restores the original rows, not removes them");
assert_eq!(
after.len(),
5,
"undo restores the original rows, not removes them"
);
}
#[test]
@@ -930,10 +1128,23 @@ fn seed_rolls_back_atomically_on_a_constraint_failure() {
))
.expect("create Bad");
let res = rt.block_on(db.seed("Bad".into(), None, Some(5), Vec::new(), Some(1), Some("seed Bad 5".into())));
assert!(res.is_err(), "seed must fail when generated rows violate the CHECK");
let res = rt.block_on(db.seed(
"Bad".into(),
None,
Some(5),
Vec::new(),
Some(1),
Some("seed Bad 5".into()),
));
assert!(
res.is_err(),
"seed must fail when generated rows violate the CHECK"
);
let rows = read_csv(&project, "Bad").map_or(0, |c| data_row_count(&c));
assert_eq!(rows, 0, "a failed seed must leave the table unchanged (atomic)");
assert_eq!(
rows, 0,
"a failed seed must leave the table unchanged (atomic)"
);
}
#[test]
@@ -942,7 +1153,14 @@ fn seed_zero_is_a_no_op() {
let rt = rt();
create_people(&db, &rt);
let res = rt
.block_on(db.seed("People".into(), None, Some(0), Vec::new(), Some(1), Some("seed People 0".into())))
.block_on(db.seed(
"People".into(),
None,
Some(0),
Vec::new(),
Some(1),
Some("seed People 0".into()),
))
.expect("seed 0 succeeds");
assert_eq!(res.produced, 0);
let rows = read_csv(&project, "People").map_or(0, |c| data_row_count(&c));
@@ -967,7 +1185,14 @@ fn seed_advises_on_a_complex_check_column() {
.expect("create Widgets");
let res = rt
.block_on(db.seed("Widgets".into(), None, Some(3), Vec::new(), Some(1), Some("seed Widgets 3".into())))
.block_on(db.seed(
"Widgets".into(),
None,
Some(3),
Vec::new(),
Some(1),
Some("seed Widgets 3".into()),
))
.expect("seed");
assert!(
res.advisory_columns.contains(&"label".to_string()),
@@ -981,10 +1206,24 @@ fn seed_foreign_keys_are_reproducible_with_a_fixed_seed() {
let rt = rt();
let seed_one = |db: &Database| {
create_users_and_orders(db, &rt, true);
rt.block_on(db.seed("Users".into(), None, Some(4), Vec::new(), Some(1), Some("seed Users 4".into())))
.expect("seed users");
rt.block_on(db.seed("Orders".into(), None, Some(8), Vec::new(), Some(99), Some("seed Orders 8".into())))
.expect("seed orders");
rt.block_on(db.seed(
"Users".into(),
None,
Some(4),
Vec::new(),
Some(1),
Some("seed Users 4".into()),
))
.expect("seed users");
rt.block_on(db.seed(
"Orders".into(),
None,
Some(8),
Vec::new(),
Some(99),
Some("seed Orders 8".into()),
))
.expect("seed orders");
};
let (p1, db1, _d1) = open_project_db();
let (p2, db2, _d2) = open_project_db();
@@ -1013,8 +1252,15 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
None,
))
.expect("create Contacts");
rt.block_on(db.seed("Contacts".into(), None, Some(5), Vec::new(), Some(42), Some("seed Contacts 5".into())))
.expect("seed");
rt.block_on(db.seed(
"Contacts".into(),
None,
Some(5),
Vec::new(),
Some(42),
Some("seed Contacts 5".into()),
))
.expect("seed");
};
let (p1, db1, _d1) = open_project_db();
let (p2, db2, _d2) = open_project_db();
@@ -1023,13 +1269,20 @@ fn seed_shortid_columns_are_reproducible_with_a_fixed_seed() {
let csv1 = read_csv(&p1, "Contacts").unwrap();
let csv2 = read_csv(&p2, "Contacts").unwrap();
assert_eq!(csv1, csv2, "shortid values must reproduce under a fixed --seed");
assert_eq!(
csv1, csv2,
"shortid values must reproduce under a fixed --seed"
);
// The shortid PK is populated with distinct 10-char base58 ids.
let codes = nth_column_values(&csv1, 0);
assert_eq!(codes.len(), 5);
let distinct: std::collections::HashSet<&String> = codes.iter().collect();
assert_eq!(distinct.len(), 5, "shortid PK values must be distinct: {codes:?}");
assert_eq!(
distinct.len(),
5,
"shortid PK values must be distinct: {codes:?}"
);
for code in &codes {
assert_eq!(code.len(), 10, "shortid should be 10 chars: {code}");
}
@@ -1105,7 +1358,10 @@ fn seed_set_fixed_value_fills_every_row() {
let csv = read_csv(&project, "Members").unwrap();
let statuses = named_column_values(&csv, "status");
assert_eq!(statuses.len(), 6);
assert!(statuses.iter().all(|s| s == "active"), "every status pinned: {statuses:?}");
assert!(
statuses.iter().all(|s| s == "active"),
"every status pinned: {statuses:?}"
);
}
#[test]
@@ -1113,7 +1369,12 @@ fn seed_set_pick_list_draws_only_from_the_list() {
let (project, db, _d) = open_project_db();
let rt = rt();
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 20 set role in ('admin', 'user') --seed 2").expect("seed");
run_seed(
&db,
&rt,
"seed Members 20 set role in ('admin', 'user') --seed 2",
)
.expect("seed");
let csv = read_csv(&project, "Members").unwrap();
let roles = named_column_values(&csv, "role");
assert!(
@@ -1131,7 +1392,10 @@ fn seed_set_as_generator_forces_the_shape() {
run_seed(&db, &rt, "seed Members 5 set name as email --seed 3").expect("seed");
let csv = read_csv(&project, "Members").unwrap();
let names = named_column_values(&csv, "name");
assert!(names.iter().all(|n| n.contains('@')), "name forced to email shape: {names:?}");
assert!(
names.iter().all(|n| n.contains('@')),
"name forced to email shape: {names:?}"
);
}
#[test]
@@ -1139,7 +1403,12 @@ fn seed_set_numeric_range_stays_within_bounds() {
let (project, db, _d) = open_project_db();
let rt = rt();
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 30 set age between 30 and 40 --seed 4").expect("seed");
run_seed(
&db,
&rt,
"seed Members 30 set age between 30 and 40 --seed 4",
)
.expect("seed");
let csv = read_csv(&project, "Members").unwrap();
for a in named_column_values(&csv, "age") {
let n: i64 = a.parse().unwrap_or_else(|_| panic!("age `{a}` not an int"));
@@ -1190,7 +1459,10 @@ fn seed_incompatible_range_is_a_friendly_error() {
// A numeric range on a text column (`name`) is rejected.
let err = run_seed(&db, &rt, "seed Members 3 set name between 1 and 10").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("between"), "range error should mention `between`: {msg}");
assert!(
msg.contains("between"),
"range error should mention `between`: {msg}"
);
}
#[test]
@@ -1221,8 +1493,12 @@ fn seed_column_fill_updates_existing_rows_without_adding() {
let before = data_row_count(&read_csv(&project, "Members").unwrap());
assert_eq!(before, 5);
let res = run_seed(&db, &rt, "seed Members.status set status in ('x', 'y') --seed 2")
.expect("column-fill");
let res = run_seed(
&db,
&rt,
"seed Members.status set status in ('x', 'y') --seed 2",
)
.expect("column-fill");
assert_eq!(res.produced, 5, "column-fill touches the 5 existing rows");
let csv = read_csv(&project, "Members").unwrap();
assert_eq!(data_row_count(&csv), 5, "no new rows added");
@@ -1240,7 +1516,10 @@ fn seed_column_fill_refuses_a_pk_target() {
create_members(&db, &rt);
run_seed(&db, &rt, "seed Members 3 --seed 1").expect("seed");
let err = run_seed(&db, &rt, "seed Members.id").unwrap_err();
assert!(format!("{err}").contains("primary key"), "PK target refused: {err}");
assert!(
format!("{err}").contains("primary key"),
"PK target refused: {err}"
);
}
#[test]
@@ -1282,7 +1561,10 @@ fn seed_column_fill_rejects_a_row_count() {
Some("seed Members.status 5".into()),
))
.unwrap_err();
assert!(format!("{err}").contains("no row count"), "count refused: {err}");
assert!(
format!("{err}").contains("no row count"),
"count refused: {err}"
);
}
#[test]
@@ -1298,7 +1580,11 @@ fn seed_column_fill_fk_target_samples_the_parent() {
assert_eq!(res.produced, 8);
let csv = read_csv(&project, "Orders").unwrap();
let user_ids = named_column_values(&csv, "user_id");
assert!(user_ids.iter().all(|v| (1..=4).contains(&v.parse::<i64>().unwrap())));
assert!(
user_ids
.iter()
.all(|v| (1..=4).contains(&v.parse::<i64>().unwrap()))
);
}
#[test]
@@ -1310,14 +1596,11 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
let rt = rt();
rt.block_on(db.create_table(
"U".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
{
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
},
],
vec![ColumnSpec::new("id", Type::Serial), {
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
}],
vec!["id".to_string()],
None,
))
@@ -1330,7 +1613,10 @@ fn seed_fixed_override_on_unique_column_is_a_friendly_error() {
);
// A short pick-list (< count) is likewise refused...
let err2 = run_seed(&db, &rt, "seed U 5 set email in ('a@b.c', 'd@e.f')").unwrap_err();
assert!(format!("{err2}").contains("distinct"), "short list refused: {err2}");
assert!(
format!("{err2}").contains("distinct"),
"short list refused: {err2}"
);
// ...but a pick-list with enough distinct values succeeds.
let ok = run_seed(
&db,
@@ -1354,14 +1640,11 @@ fn seed_column_fill_fixed_on_unique_column_is_a_friendly_error() {
let rt = rt();
rt.block_on(db.create_table(
"U".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
{
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
},
],
vec![ColumnSpec::new("id", Type::Serial), {
let mut c = ColumnSpec::new("email", Type::Text);
c.unique = true;
c
}],
vec!["id".to_string()],
None,
))
+43 -27
View File
@@ -17,7 +17,7 @@ use rdbms_playground::action::Action;
use rdbms_playground::app::App;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{
parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type,
ColumnSpec, Command, ReferentialAction, ShowListKind, Type, parse_command,
};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
@@ -108,8 +108,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let persistence = Persistence::new(project.path().to_path_buf());
let db = Database::open_with_persistence(project.db_path(), persistence)
.expect("open db with persistence");
@@ -195,8 +194,7 @@ fn show_relationships_lists_name_endpoints_and_nondefault_action() {
// Name, both endpoints, and the non-default ON DELETE CASCADE
// (ON UPDATE NO ACTION is the default and is omitted).
assert_eq!(
lines[1],
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
lines[1], " orders_customer: Customers.id → Orders.customer_id on delete cascade",
"relationship summary line: {lines:?}",
);
}
@@ -222,7 +220,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
let rt = rt();
// No schema seeded — every kind is empty.
assert_eq!(
rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(),
rt.block_on(db.show_list(ShowListKind::Tables, None))
.unwrap(),
vec!["No tables in this project yet.".to_string()],
);
assert_eq!(
@@ -231,7 +230,8 @@ fn show_lists_report_empty_collections_with_friendly_lines() {
vec!["No relationships in this project yet.".to_string()],
);
assert_eq!(
rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(),
rt.block_on(db.show_list(ShowListKind::Indexes, None))
.unwrap(),
vec!["No indexes in this project yet.".to_string()],
);
}
@@ -246,7 +246,10 @@ fn show_one_relationship_renders_detail_block() {
let rt = rt();
rt.block_on(seed_schema(&db));
let lines = rt
.block_on(db.show_list(ShowListKind::Relationships, Some("orders_customer".to_string())))
.block_on(db.show_list(
ShowListKind::Relationships,
Some("orders_customer".to_string()),
))
.expect("show relationship");
assert_eq!(lines[0], "Relationship `orders_customer`:");
assert_eq!(lines[1], " Customers.id → Orders.customer_id");
@@ -262,7 +265,10 @@ fn show_one_index_renders_detail_block() {
let rt = rt();
rt.block_on(seed_schema(&db));
let lines = rt
.block_on(db.show_list(ShowListKind::Indexes, Some("idx_orders_customer".to_string())))
.block_on(db.show_list(
ShowListKind::Indexes,
Some("idx_orders_customer".to_string()),
))
.expect("show index");
assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:");
assert!(
@@ -329,7 +335,10 @@ fn app_show_tables_dispatches_show_list_command() {
}
)
});
assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}");
assert!(
dispatched,
"submit dispatches ShowList(Tables): {actions:?}"
);
}
#[test]
@@ -337,10 +346,11 @@ fn app_renders_show_list_lines_as_system_output() {
// Feed the success event directly so the test stays
// self-contained (the worker round-trip is covered above).
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show tables",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show tables",
Mode::Simple,
));
app.update(AppEvent::DslShowListSucceeded {
command: Command::ShowList {
kind: ShowListKind::Tables,
@@ -403,10 +413,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
.expect("found");
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship orders_customer",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship orders_customer",
Mode::Simple,
));
app.update(AppEvent::DslShowRelationshipSucceeded {
command: Command::ShowList {
kind: ShowListKind::Relationships,
@@ -423,7 +434,10 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
// Both tables, box-drawing, the connector arrow, the actions line.
assert!(text.contains("Orders"), "child box: {text}");
assert!(text.contains("Customers"), "parent box: {text}");
assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}");
assert!(
text.contains('┌') && text.contains('│'),
"box drawing: {text}"
);
assert!(text.contains('▶'), "connector arrow: {text}");
assert!(text.contains("on delete cascade"), "actions: {text}");
// The diagram lines are styled (per-span runs), not plain system.
@@ -436,10 +450,11 @@ fn app_renders_show_relationship_as_a_styled_diagram() {
#[test]
fn app_show_relationship_not_found_shows_friendly_line() {
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship nope",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show relationship nope",
Mode::Simple,
));
app.update(AppEvent::DslShowRelationshipSucceeded {
command: Command::ShowList {
kind: ShowListKind::Relationships,
@@ -466,10 +481,11 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
.expect("describe Orders");
let mut app = App::new();
app.output.push_back(rdbms_playground::app::OutputLine::echo(
"show table Orders",
Mode::Simple,
));
app.output
.push_back(rdbms_playground::app::OutputLine::echo(
"show table Orders",
Mode::Simple,
));
app.update(AppEvent::DslSucceeded {
command: Command::ShowTable {
name: "Orders".to_string(),
+200 -56
View File
@@ -30,8 +30,7 @@ fn rt() -> tokio::runtime::Runtime {
fn open() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
@@ -42,8 +41,7 @@ fn open() -> (project::Project, Database, tempfile::TempDir) {
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create tempdir");
let project =
project::open_or_create(None, Some(dir.path())).expect("open or create project");
let project = project::open_or_create(None, Some(dir.path())).expect("open or create project");
let db = Database::open_with_persistence_and_undo(
project.db_path(),
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
// dropped.
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.
let rows = r
@@ -168,14 +169,21 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
.rows;
assert_eq!(rows.len(), 1);
// 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.
assert!(
r.block_on(db.insert(
"T".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()),
))
.is_err(),
@@ -185,7 +193,10 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
r.block_on(db.insert(
"T".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()),
))
.expect("qty = 7 satisfies the CHECK");
@@ -214,7 +225,10 @@ fn e2e_alter_add_column_survives_rebuild() {
r.block_on(db.insert(
"T".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()),
))
.is_err(),
@@ -257,9 +271,17 @@ fn e2e_alter_column_type_clean_and_lossy_convert() {
.rows;
assert_eq!(rows.len(), 1);
// 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".
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
// 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:?})"),
}
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
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.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]
@@ -368,11 +398,19 @@ fn e2e_alter_column_type_survives_rebuild() {
)
.expect("write script");
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())))
.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]
@@ -393,14 +431,22 @@ fn e2e_alter_column_type_is_one_undo_step() {
)
.expect("write script");
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.
assert!(
r.block_on(db.undo()).expect("undo").is_some(),
"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) -----------
@@ -410,7 +456,10 @@ fn insert_t_qty_ok(db: &Database, r: &tokio::runtime::Runtime, id: i64, qty: i64
r.block_on(db.insert(
"T".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()),
))
.is_ok()
@@ -436,14 +485,23 @@ fn e2e_add_named_check_enforced_and_survives_rebuild_with_its_name() {
"events: {events:?}"
);
// Enforced: qty = -1 refused, qty = 5 accepted.
assert!(!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");
assert!(
!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 →
// the name survived the round-trip.
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.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(
project.path().join("drop.commands"),
"alter table T drop constraint qty_positive\n",
@@ -502,13 +560,19 @@ fn e2e_add_composite_unique_enforced_and_survives_rebuild() {
))
.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");
// Survives rebuild (the unique_constraints yaml path).
r.block_on(db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())))
.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]
@@ -559,7 +623,10 @@ fn e2e_drop_composite_unique_by_derived_name() {
.is_ok()
};
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
// CONSTRAINT grammar.
@@ -572,7 +639,10 @@ fn e2e_drop_composite_unique_by_derived_name() {
// The UNIQUE no longer enforces: the previously-rejected duplicate is
// 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.
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 {
panic!("expected ReplayFailed; events: {events:?}");
};
assert!(!error.contains("--create-fk"), "no DSL flag in the SQL refusal; got: {error}");
assert!(error.contains("pid"), "names the missing column; got: {error}");
assert!(
!error.contains("--create-fk"),
"no DSL flag in the SQL refusal; got: {error}"
);
assert!(
error.contains("pid"),
"names the missing column; got: {error}"
);
assert!(
error.to_lowercase().contains("add it first")
|| 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(
"C".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()),
))
};
assert!(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");
assert!(
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]
@@ -740,7 +825,10 @@ fn e2e_drop_constraint_removes_a_named_foreign_key() {
r.block_on(db.insert(
"C".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()),
))
.is_ok(),
@@ -798,7 +886,10 @@ fn e2e_add_constraint_is_one_undo_step() {
"the ADD CONSTRAINT was one undo step"
);
// 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]
@@ -819,7 +910,10 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
.expect("db");
r.block_on(db.sql_create_table(
"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![],
vec![],
@@ -846,7 +940,8 @@ fn e2e_named_check_metadata_survives_a_fresh_rebuild() {
Persistence::new(project.path().to_path_buf()),
)
.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.
r.block_on(db.alter_drop_constraint(
@@ -878,7 +973,9 @@ fn e2e_describe_shows_table_level_constraints() {
"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!(
desc.unique_constraints,
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()))
.collect();
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:?}"
);
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()),
"the table is now Purchases, not Orders: {tables:?}"
);
assert!(csv_path(&project, "Purchases").exists(), "data/Purchases.csv written");
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
assert!(
csv_path(&project, "Purchases").exists(),
"data/Purchases.csv written"
);
assert!(
!csv_path(&project, "Orders").exists(),
"data/Orders.csv removed"
);
let rows = r
.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()),
));
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]
@@ -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.
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[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.
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[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`.
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.inbound_relationships[0].other_table, "Tree");
@@ -1216,7 +1330,9 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
"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[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).
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[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()));
// 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);
assert!(
tables.contains(&"Orders".to_string()) && !tables.contains(&"Purchases".to_string()),
"undo restored the old table name: {tables:?}"
);
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,
"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"));
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"
);
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"
);
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"
);
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"
);
@@ -1315,7 +1443,8 @@ fn e2e_rename_table_refusals() {
);
}
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"
);
@@ -1348,7 +1477,10 @@ fn e2e_alter_column_set_not_null_enforced() {
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
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:?}"
);
assert!(
@@ -1390,7 +1522,10 @@ fn e2e_alter_column_drop_not_null_allows_nulls() {
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 4, .. })
),
"events: {events:?}"
);
r.block_on(db.insert(
@@ -1416,7 +1551,10 @@ fn e2e_alter_column_set_default_applies() {
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 3, .. })),
matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 3, .. })
),
"events: {events:?}"
);
r.block_on(db.insert(
@@ -1462,7 +1600,10 @@ fn e2e_alter_column_drop_default_removes_it() {
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 4, .. })
),
"events: {events:?}"
);
r.block_on(db.insert(
@@ -1499,7 +1640,10 @@ fn e2e_alter_column_set_data_type_converts() {
.expect("write");
let events = r.block_on(run_replay(&db, project.path(), "a.commands"));
assert!(
matches!(events.last(), Some(AppEvent::ReplayCompleted { count: 4, .. })),
matches!(
events.last(),
Some(AppEvent::ReplayCompleted { count: 4, .. })
),
"events: {events:?}"
);
assert_eq!(

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