Compare commits
96 Commits
aeb92f56a7
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bd5be5ecc7 | |||
| 88830ed06a | |||
| ec3c7c304c | |||
| 41b7e9a049 | |||
| e9606b5f6d | |||
| ef99e6c676 | |||
| c30a6114b9 | |||
| fe9d58e037 | |||
| 628b250db6 | |||
| 784373a254 | |||
| dff78412dd | |||
| 028d32420d | |||
| c84a640259 | |||
| 407408ec29 | |||
| 4016c3e5cd | |||
| 1feb803aab | |||
| 93a40970c3 | |||
| 96b9581089 | |||
| b60c0bb0ec | |||
| c2baf6923b | |||
| 1660a6a17c | |||
| ea38e7a151 | |||
| 5a37437055 | |||
| 3fe62af886 | |||
| b4441507e2 | |||
| 8ae0eedd44 | |||
| 5f28de8ac3 | |||
| 888be16090 | |||
| 329adfc935 | |||
| 447112b17f | |||
| 984bc30256 | |||
| 417cbc8df9 | |||
| b6b98ad30f | |||
| 97970f2a2c | |||
| 9c4d520d5c | |||
| 47a08166a4 | |||
| 6429b56443 | |||
| 4bdfce6250 | |||
| 138e766817 | |||
| 4a5fd1b5c1 | |||
| 050b36391e | |||
| 9868442889 | |||
| e16ad50aa7 | |||
| e8fa859ab9 | |||
| ae73a4be85 | |||
| 4aeea55984 | |||
| eceedc19b7 | |||
| 8ac3537df0 | |||
| 66c8bdaa65 | |||
| 862ab21202 | |||
| ee3ccd8d77 | |||
| deb0948d6c | |||
| fde50ce3bf | |||
| 77c55fa669 | |||
| 4691d7950a | |||
| 069f9277d1 | |||
| 09b64cbfb7 | |||
| abd3739168 | |||
| a72d53de51 | |||
| 6777216e37 | |||
| 13c9c1bcd9 | |||
| 946dd88db6 | |||
| ad43cce945 | |||
| 7099bd3cde | |||
| 5908891d6b | |||
| 6778c338d4 | |||
| 823b413ca3 | |||
| a0dd202f67 | |||
| 595386e370 | |||
| 51a29e5069 | |||
| e782a280cc | |||
| 927e6b2d50 | |||
| 52860c3267 | |||
| ce153bde4c | |||
| 302329d5b2 | |||
| 65a48fa5ae | |||
| bb7887ea82 | |||
| a8f84c9d17 | |||
| 1f82fb2c79 | |||
| 44f91724b6 | |||
| c904dbb68b | |||
| fbf449f9e0 | |||
| c0cc92a741 | |||
| 10655e46de | |||
| 619c200ea1 | |||
| dfb5f0b1b1 | |||
| 39e97ac3f9 | |||
| 936d9254c0 | |||
| 44390e765d | |||
| 995c0ba8eb | |||
| c72c624daa | |||
| 9e774b2dfa | |||
| 40de389bcb | |||
| 0fcb7b1105 | |||
| cea99e8b70 | |||
| 1fad29c0f9 |
@@ -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
|
||||
@@ -2,9 +2,10 @@
|
||||
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
|
||||
# just enter the flake devShell and run cargo.
|
||||
#
|
||||
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
|
||||
# isn't clean under stock rustfmt; revisit on main). The release job (static
|
||||
# binary for D2) and the platform matrix layer on later, step by step.
|
||||
# Gate = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
|
||||
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
|
||||
# issue #35). The release job (static binary for D2) and the platform matrix
|
||||
# layer on later, step by step.
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
@@ -13,17 +14,23 @@ on:
|
||||
# run (the release workflow owns tags). Pushing commits + a tag together
|
||||
# still gates the commits via the branch push.
|
||||
branches: ['**']
|
||||
# Skip the gate for docs-only changes — markdown can't affect clippy/test.
|
||||
# A push touching code *and* docs still runs (not all files are ignored).
|
||||
# Skip the gate for changes that can't affect clippy/test — docs, markdown,
|
||||
# and the website subproject (it has its own workflow, website.yaml, that
|
||||
# builds + publishes it). A push touching crate code *and* these still runs
|
||||
# (paths-ignore only skips when *all* changed files match).
|
||||
# Note: flake/toolchain changes are NOT ignored — they can shift the
|
||||
# toolchain and thus lint/test outcomes.
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'website/**'
|
||||
- '.gitea/workflows/website.yaml'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'website/**'
|
||||
- '.gitea/workflows/website.yaml'
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
@@ -33,6 +40,8 @@ jobs:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: fmt (check, stock defaults)
|
||||
run: nix develop -c cargo fmt --check
|
||||
- name: clippy (warnings denied)
|
||||
run: nix develop -c cargo clippy --all-targets -- -D warnings
|
||||
- name: test
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
|
||||
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
|
||||
# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe)
|
||||
# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001).
|
||||
# D3 package-manager manifests layer on later.
|
||||
# The two macOS targets are built separately by the dispatched
|
||||
# release-macos.yaml (native Tart runner; ADR-ci-003 amendment), uploading to
|
||||
# the same release. D3 package-manager manifests layer on later.
|
||||
#
|
||||
# Tests run once (host) before the matrix, so a tag can never publish untested
|
||||
# code, even one pointing at a commit that was never gated on a branch.
|
||||
# code, even one pointing at a commit that was never gated on a branch. The
|
||||
# version guard (ADR-0054) refuses to publish a tag whose vX.Y.Z disagrees with
|
||||
# Cargo.toml's version, keeping `--version`, the release name, and the asset in
|
||||
# lockstep.
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
@@ -23,6 +27,27 @@ jobs:
|
||||
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: version guard — tag must equal Cargo.toml version (ADR-0054)
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Read the [package] version straight from Cargo.toml — toolchain-free
|
||||
# and robust. (An earlier `nix develop -c cargo metadata | node` version
|
||||
# broke: the flake devShell prints a banner to stdout, corrupting the
|
||||
# JSON pipe.) `^version = ` is anchored, so it matches only the package
|
||||
# version, never the `version = ` inside dependency inline tables.
|
||||
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
|
||||
echo "tag=$TAG cargo=v$VER"
|
||||
if [ -z "$VER" ]; then
|
||||
echo "ERROR: could not read the package version from Cargo.toml" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TAG" != "v$VER" ]; then
|
||||
echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: test
|
||||
run: nix develop -c cargo test --no-fail-fast
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Build the docs/marketing website and deploy it to Cloudflare Pages.
|
||||
#
|
||||
# One Pages project, two branches (no second project, no sub-folders — Pages
|
||||
# maps a branch to a *subdomain alias*, not a path):
|
||||
# main → production (the project's production branch → relplay.org)
|
||||
# website → preview (alias `website.<project>.pages.dev`; a custom
|
||||
# `staging.relplay.org` can be attached to it)
|
||||
# wrangler treats `--branch=<production-branch>` as a production deploy and any
|
||||
# other branch as a preview, so a single workflow covers both — the Pages
|
||||
# project's production branch MUST be set to `main`.
|
||||
#
|
||||
# Pure-Node build: the `.cast` recordings are committed, so no cargo/Rust is
|
||||
# needed here. Runs on the bare `ci-public` runner (node already present; pnpm
|
||||
# via corepack, pinned by package.json's `packageManager`). No job container —
|
||||
# unlike the Rust gate, this needs none.
|
||||
#
|
||||
# Required Actions secrets (set once in the repo/org settings):
|
||||
# CLOUDFLARE_API_TOKEN — token with "Cloudflare Pages: Edit" on the account
|
||||
# CLOUDFLARE_ACCOUNT_ID — the account id that owns the Pages project
|
||||
name: website
|
||||
on:
|
||||
push:
|
||||
branches: [main, website]
|
||||
# Only when the site (or this workflow) actually changes — crate-only
|
||||
# pushes don't redeploy the site.
|
||||
paths:
|
||||
- 'website/**'
|
||||
- '.gitea/workflows/website.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ci-public
|
||||
defaults:
|
||||
run:
|
||||
working-directory: website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: preflight — toolchain present
|
||||
run: |
|
||||
node --version
|
||||
corepack --version
|
||||
|
||||
- name: enable pnpm (pinned by packageManager)
|
||||
run: corepack enable
|
||||
|
||||
- name: install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: build
|
||||
run: pnpm build
|
||||
|
||||
- name: deploy to Cloudflare Pages
|
||||
shell: bash
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `dist` is relative to website/ (the working-directory). The branch
|
||||
# name decides production (main) vs preview (anything else).
|
||||
npx --yes wrangler@4 pages deploy dist \
|
||||
--project-name=relplay \
|
||||
--branch="$BRANCH"
|
||||
@@ -12,7 +12,13 @@
|
||||
*.snap.new
|
||||
*.pending-snap
|
||||
|
||||
# Website tooling — Cloudflare Wrangler local cache/state (regenerable;
|
||||
# CI deploys from website/, this dir only appears on a local wrangler run)
|
||||
.wrangler/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
# Astro/template-seeded editor configs we don't track (e.g. website/.vscode)
|
||||
.vscode/
|
||||
|
||||
@@ -37,9 +37,9 @@ Current decisions at a glance (each backed by an ADR):
|
||||
simple to advanced (ADR-0003). No other sigils.
|
||||
- **Project format:** `project.yaml` + `data/<table>.csv` +
|
||||
`history.log`; `playground.db` is a derived artifact (ADR-0004,
|
||||
amended by ADR-0015). Implemented through Iteration 4 +
|
||||
cleanup; export/import (Iter 5) and migration framework /
|
||||
--resume / persistent input history (Iter 6) pending.
|
||||
amended by ADR-0015). Fully implemented (ADR-0015 Iterations
|
||||
1–6): export/import, `--resume`, persistent input history, and
|
||||
the migration framework scaffold are all done.
|
||||
- **Project storage runtime:** every command persists through to
|
||||
db + yaml + csv + history.log in one execution context, gated
|
||||
by the combined db persistence logic; commit-db-last ordering
|
||||
@@ -108,41 +108,87 @@ Current decisions at a glance (each backed by an ADR):
|
||||
SQL `select` / `with` / `insert` / `update` / `delete`
|
||||
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
|
||||
explaining a destructive command is safe.
|
||||
- **Continuous integration & release** (developed on the `ci` branch,
|
||||
**merged to `main` 2026-06-15**; decisions in `docs/ci/adr/` —
|
||||
**ADR-ci-001/002/003**, a namespace kept separate from the main ADR
|
||||
sequence to avoid cross-branch number collisions, like the website's):
|
||||
a self-hosted
|
||||
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
||||
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
||||
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
|
||||
`cargo test` on every branch push / PR. **Release** on a `v*` tag
|
||||
(`release.yaml`): the four non-macOS **D1** targets cross-built
|
||||
with `cargo-zigbuild` (Linux musl static + standalone Windows
|
||||
`.exe`); the two macOS targets via the **dispatched**
|
||||
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
||||
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
||||
release with `.sha256`s. **`fmt` is intentionally not gated yet**
|
||||
(the tree isn't stock-`rustfmt`-clean). Now that this is on `main`,
|
||||
`release-macos` is dispatchable (`workflow_dispatch` is
|
||||
Gitea-default-branch-only) — **dispatched and verified working**: the
|
||||
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
|
||||
launch.
|
||||
- **Website & docs site** (developed on the `website` branch, **merged
|
||||
to `main`**; the branch **stays open** for future staging deploys;
|
||||
decisions in `docs/website/adr/` — **ADR-website-001**, its own
|
||||
namespace like CI's): an **Astro + Starlight + Tailwind** marketing
|
||||
landing page plus the **canonical** user docs, under `website/`.
|
||||
Showcase demos are **asciinema casts** (a scripted-input driver, paced
|
||||
+ re-recordable — *not* `history.log` replay; the `--demo` overlay,
|
||||
ADR-0047, dresses them). **Deployed to Cloudflare Pages via Gitea
|
||||
Actions** (`.gitea/workflows/website.yaml`; the crate gate is skipped
|
||||
for website-only changes). Two copy rules bind user-facing text: **no
|
||||
engine name** (continues ADR-0002) and **no "DSL"** (say "simple mode"
|
||||
/ "advanced mode"). Install docs are still partial — package-manager
|
||||
manifests + some installation instructions are pending (`requirements.md`
|
||||
D3 / DOC1).
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
.
|
||||
├── Cargo.toml # dependencies, lints (nursery)
|
||||
├── Cargo.toml / Cargo.lock # dependencies, lints (nursery)
|
||||
├── CLAUDE.md # this file
|
||||
├── flake.nix / flake.lock # pinned Rust toolchain, one source for dev + CI (ADR-ci-002)
|
||||
├── rust-toolchain.toml # toolchain pin
|
||||
├── .gitea/ # Gitea Actions workflows + prebuilt CI image (ADR-ci-001..003, website)
|
||||
├── ci/ # CI build helpers (e.g. winstub/ — Windows link stub)
|
||||
├── docs/
|
||||
│ ├── adr/ # all decision records (read 0000 first)
|
||||
│ ├── adr/ # project-wide decision records (read 0000 first)
|
||||
│ ├── ci/{adr,handoff}/ # CI subproject ADRs (ci-001..003) + handoffs
|
||||
│ ├── website/{adr,plans}/ # website subproject ADRs (website-001) + plan
|
||||
│ ├── handoff/ # session-handover notes
|
||||
│ ├── plans/ # working plans
|
||||
│ └── requirements.md # the Phase-1 checklist with progress
|
||||
├── src/
|
||||
│ ├── action.rs # Action enum (Quit / ExecuteDsl)
|
||||
│ ├── action.rs # Action enum (Quit / ExecuteDsl / …)
|
||||
│ ├── app.rs # App state + pure update() + Tier-1 tests
|
||||
│ ├── cli.rs # CLI args (--theme, --log-file)
|
||||
│ ├── cli.rs # CLI args (--theme, --log-file, --demo, --no-undo, --resume, …)
|
||||
│ ├── clipboard.rs # copy output to the system clipboard
|
||||
│ ├── completion.rs # Tab completion + schema cache
|
||||
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
|
||||
│ ├── dsl/
|
||||
│ │ ├── action.rs # ReferentialAction enum + parsing
|
||||
│ │ ├── command.rs # Command AST + RelationshipSelector + RowFilter
|
||||
│ │ ├── mod.rs # re-exports
|
||||
│ │ ├── parser.rs # parse entry point → unified-grammar walker
|
||||
│ │ ├── shortid.rs # base58 generator + validator
|
||||
│ │ ├── types.rs # user-facing Type enum + fk_target_type
|
||||
│ │ └── value.rs # Value/Bound + per-type validation
|
||||
│ │ ├── grammar/ # hand-rolled unified grammar nodes (DSL + SQL)
|
||||
│ │ ├── walker/ # grammar walker (driver / context / highlight / outcome)
|
||||
│ │ ├── command.rs parser.rs types.rs value.rs action.rs shortid.rs sql_functions.rs
|
||||
│ ├── echo.rs # command echo / SQL rendering
|
||||
│ ├── event.rs # AppEvent (input + DSL outcomes)
|
||||
│ ├── lib.rs # module re-exports for tests
|
||||
│ ├── logging.rs # tracing setup, file-backed
|
||||
│ ├── main.rs # binary entry; thin
|
||||
│ ├── mode.rs # Simple/Advanced mode enum
|
||||
│ ├── runtime.rs # Tokio loop, terminal setup, dispatch
|
||||
│ ├── friendly/ # friendly-error layer + string catalog (strings/en-US.yaml) + keys
|
||||
│ ├── input_render.rs # input-field render + ambient hint classification
|
||||
│ ├── output_render.rs # output-panel render helpers (incl. relationship diagrams)
|
||||
│ ├── logging.rs main.rs mode.rs runtime.rs # tracing / entry / mode enum / Tokio loop
|
||||
│ ├── persistence/ # csv + yaml + history IO + migrations
|
||||
│ ├── project/ # open/create, lock, naming, prettifier
|
||||
│ ├── seed/ # seed generators / heuristics / vocabulary (ADR-0048)
|
||||
│ ├── snapshots/ # insta snapshots for Tier-2 tests
|
||||
│ ├── theme.rs # light/dark themes
|
||||
│ └── ui.rs # ratatui rendering
|
||||
└── tests/
|
||||
└── walking_skeleton.rs # Tier-3 integration tests
|
||||
│ ├── theme.rs type_change.rs ui.rs undo.rs # themes / column type-change / render / undo ring
|
||||
│ └── lib.rs # module re-exports for tests
|
||||
├── tests/
|
||||
│ ├── it/ # Tier-3 integration tests (consolidated into one binary)
|
||||
│ └── typing_surface_matrix.rs # typing-surface matrix (separate Tier-3 target)
|
||||
└── website/ # Astro + Starlight docs/marketing site (ADR-website-001)
|
||||
├── src/ public/ casts-src/ # pages + assets + asciinema cast sources
|
||||
└── astro.config.mjs package.json … # deploys to Cloudflare Pages via Gitea Actions
|
||||
```
|
||||
|
||||
Key invariants in the code:
|
||||
@@ -165,7 +211,10 @@ Key invariants in the code:
|
||||
ADR. In-flight discussion stays in conversation or issues
|
||||
until it settles. The ADR-0000 index-upkeep rule applies:
|
||||
every ADR change updates `docs/adr/README.md` in the same
|
||||
edit.
|
||||
edit. ADR **numbers** are assigned at merge-to-`main` (draft
|
||||
under a placeholder `ADR-XXXX` / `draft-<slug>.md` on a
|
||||
branch) to avoid cross-branch collisions — see ADR-0000
|
||||
"Numbering discipline".
|
||||
- **Issue tracking.** Bugs and enhancements are filed as Gitea
|
||||
issues (see *Issue tracking — Gitea via `tea`* below).
|
||||
`docs/requirements.md` and the ADRs remain the source of truth
|
||||
@@ -318,16 +367,8 @@ all of `target/`, forcing a full from-scratch rebuild).
|
||||
These are explicitly tracked (mostly in `requirements.md`) but
|
||||
not yet implemented:
|
||||
|
||||
- **Project storage** (track 2): largely implemented through
|
||||
Iteration 4 + cleanup pass + safety hardening (Iterations
|
||||
1–4 of ADR-0015). Pending pieces: `export` / `import` (Iter
|
||||
5), `--resume` + persistent input history hydration +
|
||||
migration framework scaffold (Iter 6).
|
||||
- **Modify relationship** (C3a): drop+add covers the use case
|
||||
today.
|
||||
- **m:n convenience** (C4): auto-generates a junction table
|
||||
with appropriate FKs — depends on relationships being solid
|
||||
(they are).
|
||||
- **Strong syntax-help in parse errors** (H1a): point users at
|
||||
missing keywords/clauses rather than the unexpected
|
||||
character. *(H1 — the friendly **database**-error layer — is
|
||||
@@ -338,14 +379,17 @@ not yet implemented:
|
||||
- **Session log + Markdown export** (V4): the bigger UX
|
||||
project — scrollable session journal, smart structure
|
||||
rendering, save-as-markdown.
|
||||
- **Readline shortcuts** (I1b): Ctrl-A/Ctrl-E, Ctrl-W/Ctrl-K/
|
||||
Ctrl-U.
|
||||
- **Multi-line input** (I1): Enter inserts newline,
|
||||
Ctrl-Enter submits.
|
||||
- **Tab completion** (I3), **syntax highlighting** (I4).
|
||||
- **ER diagram export** (V3).
|
||||
- **CI** (TT5): test infrastructure exists; CI workflow not
|
||||
yet configured.
|
||||
- **Full TT5** (CI): the pipeline is live (see the CI decision
|
||||
above / `docs/ci/adr/`), but "all tiers on all OSes" isn't
|
||||
complete — **Windows is build-only** (cross-compiled, not
|
||||
executed: no Windows runner) and **Tier 4** (PTY, TT4) isn't
|
||||
wired in CI.
|
||||
- **D3 packaging**: prebuilt binaries + checksums ship to Gitea
|
||||
releases, but the Homebrew / Scoop / winget / `cargo binstall`
|
||||
manifests are not done.
|
||||
|
||||
## Handoff notes
|
||||
|
||||
|
||||
@@ -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
@@ -1535,7 +1535,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rdbms-playground"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
+41
-2
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -38,6 +38,44 @@ The index lists ADRs in numerical order. Each entry shows the
|
||||
number, title, and — where relevant — status annotations such as
|
||||
"Superseded by ADR-NNNN" or "Deprecated".
|
||||
|
||||
## Numbering discipline
|
||||
|
||||
ADR numbers are a single global sequence, so two branches can each grab
|
||||
"the next number" independently and collide on merge. (This happened when
|
||||
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
|
||||
renumbering the former to ADR-0044.) To prevent it:
|
||||
|
||||
**Assign an ADR's number at merge-to-`main`, not at creation.** While the
|
||||
work lives on a non-`main` branch, draft the ADR under a placeholder — an
|
||||
`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that
|
||||
way from any plan or notes. Give it the next free number only when the
|
||||
branch merges to `main`, renaming the file and updating its references in
|
||||
the same step.
|
||||
|
||||
A number is "taken" only once it appears in `main`'s `docs/adr/README.md`,
|
||||
which is the single source of truth for the next free number — never
|
||||
compute "next" from a feature branch. A branch that genuinely needs a real
|
||||
number up front may instead reserve one by landing a stub index entry on
|
||||
`main` first, but placeholder-until-merge is the default.
|
||||
|
||||
### Subproject ADR namespaces
|
||||
|
||||
A long-lived subproject developed on its own branch can escape the shared
|
||||
integer pool entirely by keeping its decision records in a **separate
|
||||
namespace**, rather than fighting collisions on every merge. The **website**
|
||||
(`docs/website/adr/`) is the first: its ADRs use a dated sequence —
|
||||
`<date>-adr-website-<NNN>.md`, referenced in prose as `ADR-website-NNN` —
|
||||
and are indexed by their own `docs/website/adr/README.md`. Because the
|
||||
date-plus-subproject prefix is disjoint from `main`'s integer sequence, a
|
||||
website ADR and a `main` ADR can never claim "the same number" again. (This
|
||||
namespace was created on 2026-06-10 after the website's ADR collided with
|
||||
`main`'s on consecutive numbers — drafted 0042, bumped to 0044, both times
|
||||
landing on a number `main` had taken; the move retired it from the pool as
|
||||
**ADR-website-001**.) The main `docs/adr/` index carries a pointer to each
|
||||
such namespace. Use this for a new subproject only when it is genuinely
|
||||
self-contained and branch-isolated; one-off cross-cutting decisions stay in
|
||||
the global sequence.
|
||||
|
||||
## Out-of-scope discipline
|
||||
|
||||
ADRs (and the plans they spawn) lean heavily on "out of scope" language.
|
||||
|
||||
@@ -213,6 +213,14 @@ working copy.
|
||||
|
||||
### 6. Persistence ordering
|
||||
|
||||
> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no
|
||||
> longer written inside the worker transaction. It is a *journal* of typed
|
||||
> commands, not state, so success journaling moved to the dispatch layer
|
||||
> (next to the already-top-level failure journaling); `commit-db-last` now
|
||||
> governs the three **state** targets only (db + `project.yaml` +
|
||||
> `data/*.csv`), which still commit atomically in the worker. The journal
|
||||
> write is best-effort (amends ADR-0040).
|
||||
|
||||
A successful user command produces effects in four targets:
|
||||
the SQLite database, `project.yaml`, the relevant
|
||||
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
|
||||
|
||||
@@ -197,6 +197,16 @@ Referenced by:
|
||||
The relationship sections retain today's plain-text format
|
||||
to leave room for the future relationship-rendering ADR.
|
||||
|
||||
> **Superseded.** ADR-0044 replaced this prose block with compact
|
||||
> diagrams on relationship-subject surfaces (`show table`,
|
||||
> `add`/`drop relationship`). **ADR-0050 (2026-06-12, issue #28)** then
|
||||
> removed the relationship block entirely from incidental-DDL structure
|
||||
> echoes (`create table`, `add`/`drop`/`rename`/`change column`,
|
||||
> `add`/`drop index`) — those render structure only — and **deleted the
|
||||
> prose renderer**. The `References:` / `Referenced by:` format above is
|
||||
> retained here as documentation/provenance should the OOS-7
|
||||
> always-prose display setting ever be built.
|
||||
|
||||
### 6. Theme integration
|
||||
|
||||
Theme colors apply to the box-drawing characters via the
|
||||
|
||||
@@ -772,6 +772,58 @@ invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`;
|
||||
`theme::function_colour_is_distinct_from_keyword_identifier_and_type`.
|
||||
See ADR-0031's status note for the grammar-side anchor.
|
||||
|
||||
## Amendment 7 — optional positional args reach the hint panel (2026-06-12)
|
||||
|
||||
Issue #26. At `seed <table> ▮` the hint panel showed only the
|
||||
`set` / `--seed` continuation chips and never mentioned the
|
||||
**optional row count** — even though a count (`seed users 50`) is
|
||||
the most common next move. The count is a bare positional
|
||||
`NumberLit` with no keyword/candidate text, so the candidate ladder
|
||||
can't surface it; and `seed <table>` is already a *complete*
|
||||
command, so the hint resolver short-circuits (empty expected set).
|
||||
|
||||
The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node;
|
||||
issue #4's CREATE-TABLE element hint) is the right tool — it shows
|
||||
prose that *introduces* a position whose first-class move has no
|
||||
candidate, with the keyword alternatives folded into the prose and
|
||||
Tab still cycling them. But it did not reach this position: a
|
||||
`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very
|
||||
next match clears — including the **empty** match of a skipped
|
||||
`Optional`. The CREATE-TABLE element survives only because it sits
|
||||
in a *required* `Repeated(min:1)`; an optional positional followed
|
||||
by more optionals (the seed count) is cleared before the resolver
|
||||
reads it.
|
||||
|
||||
### Mechanism
|
||||
|
||||
A small, general carry: when `walk_optional` skips its inner (the
|
||||
inner didn't engage), it stashes any `IntroProse` key the inner
|
||||
left in `pending_hint_mode` into a new `WalkContext` field,
|
||||
`surviving_intro_hint: Option<(key, position)>`, **before** the
|
||||
empty match clears `pending_hint_mode`. The trailing optionals,
|
||||
which are not `IntroProse`, don't overwrite it. The hint snapshot
|
||||
keeps the key **only when `position == cursor`** (the slice end),
|
||||
so it shows while the cursor sits at the count slot but not once a
|
||||
later clause (`set …`) consumes input past it, nor once the count
|
||||
itself is supplied. The resolver returns that `IntroProse` even for
|
||||
an otherwise-complete command (ahead of the empty-expected
|
||||
short-circuit).
|
||||
|
||||
The seed grammar wraps the count in
|
||||
`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose
|
||||
names the count (with its default 20) plus the `.column`
|
||||
column-fill form and the `set` / `--seed` keywords (user-chosen
|
||||
scope: mention every option). Only `IntroProse` is carried —
|
||||
`ProseOnly` / `ForceProse` mark *active* slots and reach the
|
||||
resolver through the normal path, unchanged. The CREATE-TABLE
|
||||
element (in a `Repeated`, not an `Optional`) is untouched.
|
||||
|
||||
This is a refinement of ADR-0024 §HintMode-per-node and a sibling
|
||||
of issue #4; no `AmbientHint` / renderer change. Covered by
|
||||
`input_render::{seed_count_is_advertised_at_the_optional_position,
|
||||
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
|
||||
seed_count_hint_also_fires_after_a_column_fill_target}`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Deliberately deferred to keep this ADR shippable as a single
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status
|
||||
field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the
|
||||
"non-breaking future extension" this ADR reserved — and **success
|
||||
journaling moves out of the worker to the dispatch layer**
|
||||
(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the
|
||||
failure path, where the submission mode is in scope. `status_is_ok` keys
|
||||
off the base token, so `ok:adv` replays like `ok`.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
|
||||
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
|
||||
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
|
||||
line.
|
||||
line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log`
|
||||
*journal*-write failure on a **successful** command is no longer fatal —
|
||||
journaling moved to the dispatch layer (after the db commit), so it is
|
||||
best-effort (logged + ignored), consistent with the failure-journal path.
|
||||
State-write failures (yaml/csv/db) remain fatal.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ Prose-retained surfaces (**unchanged** from ADR-0016 §5):
|
||||
`add`/`drop index` — keep the terse `References:` /
|
||||
`Referenced by:` prose. A simple `add column` on a heavily-related
|
||||
table should not print a wall of diagrams.
|
||||
*(**Superseded 2026-06-12 by ADR-0050** (issue #28): these incidental
|
||||
DDL echoes now render **structure only** — no relationship block at
|
||||
all, neither prose nor diagram. The prose renderer was deleted. The
|
||||
diagram surfaces below are unchanged.)*
|
||||
|
||||
So this **partially supersedes ADR-0016 §5**: the prose block is
|
||||
replaced by diagrams on the relationship-subject surfaces and
|
||||
|
||||
@@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery).
|
||||
submits over a multi-logical-line buffer. DA3/DA4 keep a single
|
||||
logical line; this remains a separate, deferred feature.
|
||||
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
|
||||
not touched here.
|
||||
not touched here. *(Superseded 2026-06-12: I1b is now in scope and
|
||||
decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input
|
||||
field, issue #29.)*
|
||||
- **Cross-session sidebar persistence** — visibility is session-only
|
||||
(DB1); persisting it would amend ADR-0015.
|
||||
- **The output panel as a third navigation focus target** — navigation
|
||||
@@ -554,3 +556,27 @@ All tiers green, zero skips; clippy clean (nursery).
|
||||
and is accepted: 90 is the screencast width, real terminals sit well
|
||||
to one side of it, and `Ctrl-O` peek covers the in-between case. The
|
||||
`90` threshold is a tunable constant.
|
||||
|
||||
## Amendment 1 — focus accent is a colour, not bold (2026-06-12)
|
||||
|
||||
Issue #25. DC3's "accent border" on the focused sidebar panel was
|
||||
first implemented as bright `theme.fg` **plus `Modifier::BOLD`** on
|
||||
the box-drawing border. Bold box-drawing glyphs render as broken /
|
||||
gapped line-art in the asciinema player used for the website casts
|
||||
(vertical strokes don't connect to the corner glyphs) and are
|
||||
fragile in some terminals.
|
||||
|
||||
**`panel_border_style` now marks focus with a non-bold accent
|
||||
colour — `theme.mode_simple` (blue) — and never `Modifier::BOLD` on
|
||||
a border.** The unfocused border stays muted `theme.border`. This
|
||||
makes the ADR's "accent border (lazygit convention)" wording
|
||||
literal — it is now a true accent hue rather than bold bright-fg —
|
||||
and is what renders cleanly in casts. Bold remains fine on *text*
|
||||
spans (titles, key hints); the constraint is specifically that
|
||||
box-drawing borders carry no bold attribute.
|
||||
|
||||
Note: this is a pure style change. The Tier-2 snapshots are
|
||||
text-only (`render_to_string` captures cell symbols, not styles),
|
||||
so none needed re-accepting; the Tier-1 `panel_border_style`
|
||||
assertion was updated and a render-level test now checks the actual
|
||||
border cells carry the accent colour and no bold.
|
||||
|
||||
@@ -414,5 +414,41 @@ time-boxed-`recv` path. We therefore test the **pure pieces**
|
||||
exhaustively (label fn, capture state machine, nearest-deadline helper)
|
||||
and assert plumbing via Tier-3, rather than over-claiming an integration
|
||||
test of the `tokio` timeout itself.
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
## Amendment 1 — `Ctrl-G` demo-mode alias for F1 (2026-06-15)
|
||||
|
||||
**Context.** The contextual `hint` overlay (ADR-0053 / H2) is opened with
|
||||
**F1**. But F1 reaches the app only as an escape sequence (`\eOP` /
|
||||
`\e[11~`), and the `autocast` recorder used for our screencasts **cannot
|
||||
emit escape sequences** — so a cast can never trigger F1, and the single
|
||||
most teaching-relevant overlay is unreachable in recordings. The same
|
||||
wall already bit step-captions (which is why `Ctrl+]`, a single control
|
||||
byte, was chosen over `Ctrl+!`).
|
||||
|
||||
**Decision.** In **demo mode only**, **`Ctrl-G`** is an alias for F1. It
|
||||
runs the exact F1 hint logic (live-input → form hint; empty input →
|
||||
recent-error / getting-started) and is **badged as `[F1]`** (not
|
||||
`[CTRL-G]`) so a recorded cast is visually identical to a genuine F1
|
||||
press. `Ctrl-G` is the only viable choice: it is a single legacy control
|
||||
byte autocast can send, whereas `Ctrl`+digit (e.g. the mnemonic `Ctrl-1`)
|
||||
is **not encodable in a legacy terminal at all** — digits have no control
|
||||
byte, so `Ctrl-1` arrives as a bare `1`; the kitty protocol *would* encode
|
||||
it but only as an escape sequence (the very thing autocast can't send),
|
||||
and this app deliberately does not enable keyboard-enhancement flags.
|
||||
|
||||
**Why demo-gated.** The shipped keymap stays F1-only — a real user never
|
||||
trips the alias, and demo mode is also the mode teachers/presenters run,
|
||||
so the alias is available exactly where it's wanted. Outside demo mode
|
||||
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
|
||||
excludes CONTROL, so no `g` is typed).
|
||||
|
||||
**Scope.** `hint_key` guard in `App::handle_key` gains the demo-gated
|
||||
`Ctrl-G` disjunct; `demo_badge_label` maps `Ctrl-G → [F1]` (consulted
|
||||
only in demo mode). Test-first: three `app.rs` Tier-1 tests (alias fires
|
||||
on input + on empty input; inert when demo off) + the badge-map
|
||||
assertion. The keybinding strip (ADR-0051) is **not** changed — F1 stays
|
||||
the advertised key; `Ctrl-G` is a recorder aid, and the badge already
|
||||
reads `[F1]`.
|
||||
|
||||
*(Editorial: this amendment also removed two stray `</content>` /
|
||||
`</invoke>` lines accidentally committed at the end of this file.)*
|
||||
|
||||
@@ -317,6 +317,8 @@ with the implementation):
|
||||
| `url`/`website`/`homepage` · `color`/`colour` | URL / hex colour | text |
|
||||
| `price`/`amount`/`cost`/`salary`/`balance`/`total` | currency-range number | numeric |
|
||||
| `age` · `quantity`/`qty`/`stock`/`count` | 18–80 · small int | numeric |
|
||||
| `year`/`*_year`/`published`/`founded` (Amendment 1) | bounded year (birth window for `birth`/`born`/`dob`, else 1950–2025) | int |
|
||||
| `priority`/`prio` · `severity` · `rating`/`stars` (Amendment 1) | built-in `PickFrom` value set | text/int |
|
||||
| `date`/`*_date` | date, recent ~3 yr window | date |
|
||||
| `dob`/`birthday` | date, adult window (18–80 yr ago) | date |
|
||||
| `timestamp`/`datetime` · `created_at`/`updated_at`/`*_at` | datetime, recent window (`updated_at` ≥ `created_at`) | datetime |
|
||||
@@ -675,3 +677,66 @@ the regression floor.
|
||||
derive-`IN`-else-friendly-fail tier.
|
||||
- **`set`-driven NULL / per-column report / recursive parent seed:**
|
||||
deferred — see Out of scope.
|
||||
|
||||
## Amendment 1 — year-as-int + conventional choice sets (2026-06-12)
|
||||
|
||||
Two SD2-style refinements to the D7 catalogue, surfaced while writing
|
||||
the website `seed` docs. Both are additive name rules; no change to D8
|
||||
(type fallback), the executor, or the grammar.
|
||||
|
||||
### Issue #33 — year-like `int` columns
|
||||
|
||||
A column such as `published` or `birth_year` was just an `int`, so it
|
||||
fell through to the unbounded type-based `int` path (D8) and produced
|
||||
nonsense like `9419` or `1426` — implausible as years, undercutting the
|
||||
"realistic data" pedagogy. Added an **`int`-gated** year rule, placed
|
||||
*after* the quantity rule (so `year_count` stays a count):
|
||||
|
||||
- `year` / `*_year` / `published` / `founded` → **`YearRecent`**, a
|
||||
bounded window of **1950–2025** (75 years relative to the fixed
|
||||
`REF_YEAR`, wide enough for published books / founding years /
|
||||
release years; matches the issue's own `between 1950 and 2020`
|
||||
workaround).
|
||||
- the same with a `birth` / `born` / `dob` token (e.g. `birth_year`) →
|
||||
**`YearBirth`**, mirroring the existing `dob → DateAdult` adult birth
|
||||
window as years (**1945–2007**).
|
||||
|
||||
Both emit a plain `int`. `published` / `founded` are included
|
||||
(user-confirmed): an `int` so named is almost always a year (a flag
|
||||
would be `is_published`). The generators are **not** added to the D9
|
||||
named-generator vocabulary — explicit control stays with `set <col>
|
||||
between <lo> and <hi>`.
|
||||
|
||||
### Issue #34 — built-in value sets for conventional choice names
|
||||
|
||||
D12 deliberately does not guess values for enum-ish names. For a few,
|
||||
though, there is a near-canonical small set that reads far better than
|
||||
lorem text. Added a **type-gated `PickFrom`** lookup (reusing the
|
||||
existing generator — no new machinery), placed ahead of the enum-ish
|
||||
fallthrough:
|
||||
|
||||
| Name (tokens) | text | int |
|
||||
|---|---|---|
|
||||
| `priority` / `prio` | `low`/`medium`/`high` | `1`/`2`/`3` |
|
||||
| `severity` | `low`/`medium`/`high`/`critical` | `1`/`2`/`3`/`4` |
|
||||
| `rating` / `stars` | — | `1`–`5` |
|
||||
|
||||
A user-declared `IN`-CHECK (D17) still wins — it is resolved before the
|
||||
heuristics. Any name that gains a set is **removed from the enum-ish
|
||||
advisory trigger** (`priority` left `ENUM_TOKENS`); since the advisory
|
||||
(D13) only fires on `Generator::Generic`, a `PickFrom` name is excluded
|
||||
either way, but the removal keeps `is_enum_ish` semantically "names seed
|
||||
still can't guess".
|
||||
|
||||
**`status` is deliberately excluded** (user-confirmed on the issue): its
|
||||
real values are too domain-specific (`active/inactive`,
|
||||
`open/closed/pending`, `draft/published`, …), so it keeps the D12
|
||||
"don't guess" stance — generic text + the advisory pointing at `set
|
||||
status in (…)`. `state` stays its US-state-name generator (D7);
|
||||
`type`/`kind`/`category`/`stage`/`gender` and `size`/`tier`/`plan` were
|
||||
considered and left to the advisory.
|
||||
|
||||
**Website follow-up** (tracked on the `website` branch, not here): the
|
||||
`seed` cast exercises a `tickets` table with `priority`; it should be
|
||||
re-recorded so the table tightens once `priority` collapses to a short
|
||||
value — likely subsumed by the pre-publication cast sweep.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b)
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29**
|
||||
("Command input keystroke support") and the deferred **I1b** readline
|
||||
requirement in `requirements.md`. Every fork below was escalated to the
|
||||
user and user-chosen before any code was written; implemented test-first
|
||||
(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean).
|
||||
|
||||
This ADR **amends ADR-0046**, which explicitly listed "readline
|
||||
shortcuts (I1b)" in its out-of-scope set: that item is now in scope and
|
||||
decided here. It is orthogonal to ADR-0003's input-*mode* model (simple
|
||||
vs advanced, the `:` sigil) — these are editing keys within the input
|
||||
field, not mode or sigil changes — and it extends the single-line cursor
|
||||
editing already shipped under requirement **I1a** (Left/Right/Home/End/
|
||||
Backspace/Delete, `app.rs`).
|
||||
|
||||
## Context
|
||||
|
||||
The input field already supported in-line cursor editing (I1a): Left/
|
||||
Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete.
|
||||
Two gaps remained, raised in issue #29:
|
||||
|
||||
1. No way to **clear a partly-typed command** in one keystroke — a user
|
||||
who started typing the wrong thing had to hold Backspace.
|
||||
2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for
|
||||
keyboards without Home/End and for muscle-memory in a command-driven
|
||||
workflow. This is requirement I1b, deferred by ADR-0046.
|
||||
|
||||
`Esc` was free in the input field except that a *live Tab-completion
|
||||
memo* consumes it first (to undo the completion in one keystroke,
|
||||
ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C
|
||||
(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption
|
||||
toggle, ADR-0047) — none collide with a/e/w/k/u.
|
||||
|
||||
## Decision
|
||||
|
||||
Bind the following in the input field (non-modal, non-navigation,
|
||||
both input modes), in `App::handle_key`:
|
||||
|
||||
| Key | Action |
|
||||
|-----------|---------------------------------------------------|
|
||||
| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)|
|
||||
| `Ctrl-A` | Cursor to line start (alias of Home) |
|
||||
| `Ctrl-E` | Cursor to line end (alias of End) |
|
||||
| `Ctrl-W` | Delete the word before the cursor |
|
||||
| `Ctrl-K` | Kill from the cursor to end of line |
|
||||
| `Ctrl-U` | Kill from start of line to the cursor |
|
||||
|
||||
Behavioural rules:
|
||||
|
||||
- **Esc precedence.** A live completion memo still wins: the first Esc
|
||||
undoes the completion (ADR-0022), and Esc only *clears* when no memo
|
||||
is alive. This is a natural progression — Esc once to back out the
|
||||
completion, Esc again to clear.
|
||||
- **Esc does not clear while navigating the sidebar.** When a sidebar
|
||||
panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every
|
||||
key to the navigation handler *before* the input-field keymap, where
|
||||
Esc exits navigation mode (`nav_exit`). Entering nav mode never
|
||||
touched the input buffer, so Esc-to-close-the-panel returns focus to
|
||||
the input with the partly-typed command intact — it cannot reach the
|
||||
clear binding. Locked by a regression test.
|
||||
- **Single Esc clears** (user-chosen over double-Esc). Discoverable and
|
||||
fast; the trade-off (an accidental Esc wipes an unsubmitted line) was
|
||||
accepted. A submitted line is always recoverable from history; only
|
||||
*unsubmitted* draft text is lost.
|
||||
- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E,
|
||||
like Home/End, move the cursor without ending history recall.
|
||||
- **Buffer-mutating keys end history navigation.** Esc-clear and
|
||||
Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line
|
||||
*is* the new draft), matching Backspace/Delete.
|
||||
- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of
|
||||
trailing whitespace, then the preceding run of non-whitespace; word
|
||||
boundaries are found on char boundaries so multi-byte words delete
|
||||
cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W
|
||||
leaves the suffix intact).
|
||||
|
||||
Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`,
|
||||
`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` /
|
||||
`delete_before_cursor` style.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable
|
||||
over accident-proof.
|
||||
- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just
|
||||
the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement
|
||||
in one pass rather than leaving Ctrl-W/K/U for a follow-up.
|
||||
- **Documentation:** a new ADR (this one), recording the input-field
|
||||
keymap convention and amending ADR-0046's OOS list — over folding it
|
||||
into ADR-0046 or shipping it I1a-style with no ADR.
|
||||
|
||||
## Consequences
|
||||
|
||||
- I1b is complete; `requirements.md` I1b moves to `[x]`.
|
||||
- The new keys are **not yet advertised on screen.** Surfacing per-focus
|
||||
keybindings in the bottom status line is issue #27's domain (a
|
||||
separate, in-design UX change); this ADR makes the keys *work*, #27
|
||||
will make them *discoverable*.
|
||||
- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl-
|
||||
chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are
|
||||
glyph-less and would be invisible in an asciinema cast. Whether to add
|
||||
`[CTRL-A]`…`[CTRL-U]` badges is left to ADR-0047's scope and flagged
|
||||
as a follow-up — it is a cast-polish concern, not a #29 requirement.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- On-screen keybinding hints for the input field (issue #27).
|
||||
- Demo badges for the new chords (ADR-0047 follow-up; flagged above).
|
||||
- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still
|
||||
deferred.
|
||||
- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not
|
||||
requested; not part of I1b.
|
||||
@@ -0,0 +1,119 @@
|
||||
# ADR-0050: Incidental-DDL confirmations omit relationship info (structure-only)
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-12 (issue #28).** Closes Gitea **#28**.
|
||||
Both forks below were escalated to the user and user-chosen before any
|
||||
code was written; implemented test-first. **Supersedes** the
|
||||
incidental-DDL clause of **ADR-0044 §1** and the part of **ADR-0016 §5**
|
||||
that placed a relationship block under every structure echo. The
|
||||
diagram behaviour ADR-0044 introduced for relationship-subject surfaces
|
||||
is unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0016 §5 rendered a structure box followed by a plain-text
|
||||
`References:` / `Referenced by:` relationship block under **every**
|
||||
structure echo. ADR-0044 §1 split that by surface:
|
||||
|
||||
- **Relationship-subject surfaces** — `show table <T>`,
|
||||
`add 1:n relationship`, `drop relationship`, `show relationship <name>`
|
||||
— render relationships as compact **diagrams** (the user asked for, or
|
||||
acted on, a relationship).
|
||||
- **Incidental DDL auto-shows** — `create table`, `add`/`drop`/`rename`/
|
||||
`change column`, `add`/`drop index` — kept the terse **prose** block,
|
||||
with the rationale *"a simple `add column` on a heavily-related table
|
||||
should not print a wall of diagrams."*
|
||||
|
||||
Issue #28 reconsiders the deeper question ADR-0044 did not ask: should
|
||||
an incidental-DDL confirmation show relationship information **at all**?
|
||||
Owner preference: **no.** A confirmation echo should focus on the change
|
||||
just made — the new / updated structure — not re-print the table's
|
||||
relationships, which the user did not touch. The terse prose was the
|
||||
lesser of "prose vs diagram", but the right answer for these surfaces is
|
||||
**neither**.
|
||||
|
||||
## Decision
|
||||
|
||||
**Incidental-DDL confirmation echoes render the structure only** — the
|
||||
table-name header, the column / type / constraints box, the `Indexes:`
|
||||
section, and the constraint section — with **no relationship section**
|
||||
(neither prose nor diagram).
|
||||
|
||||
- **Scope: all incidental DDL** (user-chosen, over "just `add column`"):
|
||||
`create table`, `add column`, `drop column`, `rename column`,
|
||||
`change column`, `add index`, `drop index`. The rule is uniform — a
|
||||
structural edit confirms structure, never relationships. (For a
|
||||
freshly `create`d table the relationship section was empty anyway; the
|
||||
rule still applies for consistency of the mental model.)
|
||||
- **Relationship-subject surfaces are unchanged.** `show table`,
|
||||
`add`/`drop relationship`, and `show relationship <name>` still render
|
||||
diagrams. Relationships appear **only** when the user asks for them
|
||||
(`show table` / `show relationship`) or acts on one
|
||||
(`add`/`drop relationship`).
|
||||
- **No information is lost.** Anything dropped from an incidental echo is
|
||||
one `show table <T>` away.
|
||||
|
||||
### Mechanism
|
||||
|
||||
The `handle_dsl_success` routing (`app.rs`) is **unchanged**: it still
|
||||
sends relationship-subject commands to the diagram renderer and
|
||||
everything else to `render_structure`. The change is entirely inside
|
||||
`render_structure` (`output_render.rs`): it no longer appends the
|
||||
relationship block — `render_structure` = structure box + indexes +
|
||||
constraints. All of `render_structure`'s callers are incidental DDL
|
||||
(verified), so this single edit covers the whole scope with no
|
||||
per-command branching.
|
||||
|
||||
### Prose renderer disposition
|
||||
|
||||
The orphaned prose renderer (`relationship_prose_lines`, and its
|
||||
sole helper `cols_disp`) is **deleted** (user-chosen, over retaining it
|
||||
dormant). After this change no shipped surface renders the prose form,
|
||||
so keeping it would be dead code. The prose format remains documented in
|
||||
**ADR-0016 §5** and in git history; if ADR-0044's OOS-7 user-configurable
|
||||
"always-prose" display setting is ever built, it re-introduces the ~30
|
||||
lines from that provenance.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Scope:** *all incidental DDL*, not just `add column` — the owner's
|
||||
rationale ("confirm the change, not untouched relationships") applies
|
||||
uniformly, gives a clean mental model, and is the simpler edit (remove
|
||||
one call vs a per-command flag).
|
||||
- **Prose renderer:** *delete* it — no dead code — over retaining a
|
||||
public, tested-but-uncalled renderer for the speculative OOS-7 setting.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Incidental confirmations are shorter and on-topic; a heavily-related
|
||||
table no longer prints a relationship wall after `add column`.
|
||||
- One relationship renderer (prose) leaves the codebase; the diagram
|
||||
renderer (ADR-0044) is the only relationship render path that ships.
|
||||
- `requirements.md` is unaffected (this is an ADR-tracked refinement of a
|
||||
decided area, like ADR-0044 itself); the change is cross-referenced
|
||||
from the commit + this ADR.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unit (`output_render.rs`):** the prose-asserting test
|
||||
`render_structure_with_relationships` (+ its snapshot) is removed; a
|
||||
new test asserts `render_structure` on a description **carrying** both
|
||||
inbound and outbound relationships emits the structure box but **no**
|
||||
`References:` / `Referenced by:` lines. The box/index/constraint tests
|
||||
are unaffected (their descriptions have no relationships).
|
||||
- **Integration (`walking_skeleton.rs`):** the misnamed
|
||||
`add_relationship_flow_shows_inbound_section_on_parent` (which sends an
|
||||
`AddColumn` and asserted the inbound prose) is inverted + renamed to
|
||||
assert the add-column confirmation shows the structure but **omits**
|
||||
the relationship prose.
|
||||
- **Unchanged:** the diagram tests (`show_list.rs` `show table`,
|
||||
`walking_skeleton.rs` `add relationship`) still pass — they already
|
||||
assert prose is absent and diagrams are present.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The diagram form and its per-surface defaults (ADR-0044) — unchanged.
|
||||
- The OOS-7 user-configurable display setting (always-prose / -diagram /
|
||||
auto-by-width) — still a future follow-up; this ADR removes the prose
|
||||
*renderer*, not the *idea* of a prose mode.
|
||||
@@ -0,0 +1,147 @@
|
||||
# ADR-0051: Bottom keybinding strip — context- and state-aware
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
|
||||
below were escalated to the user and user-chosen before any code was
|
||||
written; to be implemented test-first. Builds on ADR-0046 (nav focus),
|
||||
ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
|
||||
advertises), and ADR-0022 (the Tab-completion memo).
|
||||
|
||||
## Context
|
||||
|
||||
The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
|
||||
with typed-command words: `Enter submit · : advanced once · mode
|
||||
advanced switch · Ctrl-C quit`. That is redundant — the hint panel
|
||||
already teaches `help` and `Enter` when the input is empty — and it is
|
||||
static apart from a three-way mode branch, so it never reflects what the
|
||||
user can actually do *right now* (navigating the sidebar, cycling a
|
||||
completion, browsing history, editing a line).
|
||||
|
||||
Issue #27: repurpose the line as a **keybindings-only** strip that is
|
||||
**context-sensitive to nav focus** and **state-aware of the current
|
||||
transient interaction**, and move mode discovery into the empty-input
|
||||
hint.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The strip is keybindings-only and state-selected
|
||||
|
||||
A single pure function `status_bar_bindings(app) -> Vec<Binding>`
|
||||
computes the strip from app state; `render_status_bar` is a thin
|
||||
renderer over it (so the binding sets are unit-testable without a
|
||||
Frame). `history_cursor` is private to `App`, so a small
|
||||
`pub fn is_browsing_history(&self) -> bool` accessor exposes the
|
||||
history-navigation predicate; `mode` / `nav_focus` / `last_completion`
|
||||
are already `pub` and `effective_mode()` is a `pub` method. The state is
|
||||
chosen by **priority — first match wins**:
|
||||
|
||||
| Priority | State (predicate) | Strip |
|
||||
|---|---|---|
|
||||
| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
|
||||
| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
|
||||
| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
|
||||
| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
|
||||
| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
|
||||
|
||||
Priority order matters: a completion memo or history navigation is a
|
||||
non-empty-input situation, so states 2 and 3 must precede state 4. The
|
||||
sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
|
||||
outright.
|
||||
|
||||
### 2. Mode discovery moves off the strip, into the empty-input hint
|
||||
|
||||
The typed-command advertisements (`mode advanced` / `mode simple`
|
||||
switch, the `:` one-shot) leave the strip — they are not keystrokes.
|
||||
Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
|
||||
`(None, None)` arm), in **simple mode only**:
|
||||
|
||||
- **Simple:** `… · \`mode advanced\` for SQL`
|
||||
- **Advanced (persistent):** no pointer.
|
||||
|
||||
The pointer omits the verb "type" — the surrounding prompt already
|
||||
implies it (we don't say "type `help`" either). Advanced mode shows
|
||||
**no** pointer (user decision, post-trial): a user who switched into
|
||||
advanced mode knows how they got there, and `help` covers the way back —
|
||||
a "switch back" pointer only reads naturally in the moment right after
|
||||
switching, so it earns its space poorly.
|
||||
|
||||
The one-shot advanced state's old `Backspace cancel one-shot` label is
|
||||
**subsumed** by the editing state (the input is non-empty in one-shot;
|
||||
Esc-clear and Backspace both cancel it). No behaviour is lost — only the
|
||||
dedicated label.
|
||||
|
||||
### 3. Width: no drop machinery; a budget test instead
|
||||
|
||||
The longest strip (state 4, editing) is ≈ **65 display columns**, which
|
||||
fits every supported width (90-col screencasts, 80-col terminals) with
|
||||
margin — so the priority-drop / abbreviation machinery considered would
|
||||
never trigger and is not built (user-confirmed). Ratatui's existing
|
||||
**clip-at-edge** is the trivial fallback for pathologically narrow
|
||||
(< 65-col) terminals. Instead, a **width-budget unit test** pins the
|
||||
longest rendered strip within an 80-col budget, keeping the strip lean
|
||||
*by construction* — a future over-long strip fails the test rather than
|
||||
silently clipping in a cast.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Editing state — yes:** when the input has text, surface the #29
|
||||
readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
|
||||
(nav/complete/history) when empty. (vs not advertising the #29 keys.)
|
||||
- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
|
||||
a near-universal convention; omitting it keeps the strips lean and
|
||||
matches the issue's sketch.
|
||||
- **Width — budget test, no drop logic** (vs graceful priority-drop /
|
||||
abbreviation): the strips fit at supported widths, so the machinery
|
||||
would be dead weight (user's own observation).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The strip now teaches the keys for the *current* situation; learners
|
||||
see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
|
||||
exactly while editing, etc.
|
||||
- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
|
||||
closing that ADR's deferred item.
|
||||
- 15 existing full-panel insta snapshots churn (the bottom line — and,
|
||||
on empty-input views, the hint pointer — changes in every one,
|
||||
including the rebuild-confirm modal view, whose modal box is itself
|
||||
unchanged); each diff was reviewed, not blind-accepted.
|
||||
- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
|
||||
change is cross-referenced from the commit + this ADR.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
|
||||
key set for each of the five states (sidebar, completion-live,
|
||||
history-nav, editing, default) — the completion/history states driven
|
||||
through real key events (`update`) so the predicate transitions are
|
||||
exercised, the others by setting `App` fields; plus the width-budget
|
||||
assertion across states. (Per-state coverage is these unit tests, not
|
||||
snapshots — a one-line strip is asserted more precisely by its exact
|
||||
key list than by a full-panel snapshot.)
|
||||
- **Tier-1:** the empty-input hint appends the correct mode pointer in
|
||||
Simple vs Advanced, and does **not** append it when an ambient hint is
|
||||
showing (non-empty input).
|
||||
- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
|
||||
submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
|
||||
renamed to assert the keystroke-only, state-aware strip end-to-end
|
||||
through the real render path (default → editing transition).
|
||||
- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
|
||||
reviewed — strip line and/or hint pointer only).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Modal-aware strip.** While a modal is open (load picker, rebuild /
|
||||
undo confirm) it owns the keyboard and carries its own in-box key
|
||||
hints; the bottom strip under a modal computes from input state
|
||||
exactly as it does today (modals render *over* the status bar). This
|
||||
issue does not redesign the modal case — pre-existing behaviour,
|
||||
unchanged and not worsened.
|
||||
- A persistent/togglable help overlay listing *all* keys (the strip is a
|
||||
contextual subset, not a cheatsheet).
|
||||
- Per-key colour theming beyond the existing key/label/separator styles.
|
||||
- Localisation of the new label strings beyond adding catalog entries.
|
||||
- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
|
||||
editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
|
||||
stay within the width budget; Ctrl-K/U remain unadvertised muscle
|
||||
memory.
|
||||
@@ -0,0 +1,250 @@
|
||||
# ADR-0052: Mode-tagged history for cross-mode recall
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
|
||||
both the feature ("reuse advanced history commands in simple mode by
|
||||
prepending `:`") and the bug reported in its comment (the `:` one-shot
|
||||
prefix lost across sessions). All forks user-chosen before any code.
|
||||
**Amends ADR-0034** (journal 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, no longer fatal); references ADR-0003 (the `:` one-shot
|
||||
sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
|
||||
(pre-build `/runda`, then a second `/runda` that drove the journaling
|
||||
relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
|
||||
skip (1 ignored), clippy clean.**
|
||||
|
||||
> **Why journaling moved (the key architectural turn).** The first draft
|
||||
> kept journaling in the worker and threaded the mode down to it (~30-site
|
||||
> plumbing). On review the user asked the right question: why is the
|
||||
> journal written deep in the worker at all, when the failure path already
|
||||
> journals at the top of the chain where command + mode + outcome are all
|
||||
> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
|
||||
> not *state*. So success journaling moved up next to the failure path
|
||||
> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
|
||||
> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
|
||||
> now writes only the state sources (yaml/csv). Consequence: the journal
|
||||
> write is best-effort (the command is already committed), consistent with
|
||||
> the failure path — see §5.
|
||||
|
||||
## Context
|
||||
|
||||
The input-history ring and `history.log` carry **no mode information**,
|
||||
which causes two coupled problems:
|
||||
|
||||
1. **Feature gap.** A command typed in advanced mode (`select * from T`)
|
||||
is stored bare. Recalled in simple mode it is not valid DSL → it just
|
||||
errors. There is no way to know it was an advanced (SQL) command and
|
||||
offer it back in a runnable form.
|
||||
|
||||
2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
|
||||
mode recalls correctly **in-session** (the in-memory ring stores the
|
||||
raw `:select 1`), but after quit+resume it comes back **without** the
|
||||
`:` and is unusable. Root cause: the ring stores the raw input
|
||||
(`:select 1`), but the worker journals the **stripped** `effective_input`
|
||||
(`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
|
||||
so the on-disk `source` never carried the `:`, and hydration loses it.
|
||||
|
||||
Both reduce to: **history does not record the submission mode**, and the
|
||||
in-memory and on-disk representations disagree about the `:`.
|
||||
|
||||
## Decision
|
||||
|
||||
Record the **submission mode** per history entry, keep the on-disk
|
||||
`source` **canonical** (stripped — replay is unaffected), and have
|
||||
**recall reconstruct the runnable line** for the current mode.
|
||||
|
||||
### 1. In-memory ring stores the `:`-prefixed runnable form
|
||||
|
||||
`App.history` stays `Vec<String>` — no type change, so the public ring,
|
||||
the `ProjectSwitched` payload, and `seed_history` are untouched. An
|
||||
**advanced** entry is stored in its **simple-mode runnable form**, the
|
||||
`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
|
||||
stored bare. This is exactly what the in-session one-shot ring already
|
||||
does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
|
||||
commands too, and made reconstructable on hydration. Because a simple
|
||||
DSL command can never begin with `:` (the sole sigil, ADR-0003), a
|
||||
leading `:` unambiguously marks an advanced entry.
|
||||
|
||||
`submit` builds the stored line from the submission: advanced →
|
||||
`": " + effective_input` (the `: ` matches the auto-space the typed
|
||||
one-shot inserts), simple → `effective_input`. This is computed **after**
|
||||
`effective_input` (today `push_history` runs on the raw `trimmed` before
|
||||
stripping; the reorder also drops a bare `:`, which never executed). The
|
||||
draft (`history_draft`) stays a plain `String`. `push_history` itself is
|
||||
unchanged — it still takes one `&str`.
|
||||
|
||||
### 2. Recall strips the `:` for advanced mode
|
||||
|
||||
`history_back` / `history_forward` set `self.input` from the stored
|
||||
string, then strip a leading `:` **iff the current persistent mode is
|
||||
Advanced**:
|
||||
|
||||
```
|
||||
if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
|
||||
```
|
||||
|
||||
So an advanced entry recalls as `: select * from T` in **simple** mode
|
||||
(runs via the one-shot escape — the feature, and the cross-session bug
|
||||
fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
|
||||
simple entry recalls bare in either mode (simple DSL already runs in
|
||||
advanced mode — issue #30). In-session and cross-session paths share the
|
||||
same stored form, so they finally agree.
|
||||
|
||||
### 3. On-disk: a mode tag in the status field
|
||||
|
||||
The record stays three pipe-separated fields `<ts>|<status>|<source>`
|
||||
(so `source` remains the last, pipe-tolerant, canonical field — replay
|
||||
reads it unchanged). The **status token** gains an optional `:adv`
|
||||
suffix:
|
||||
|
||||
| Submission | Success | Failure |
|
||||
|---|---|---|
|
||||
| Simple | `ok` | `err` |
|
||||
| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
|
||||
|
||||
ADR-0034 §1 already reserved the status field for "additional values …
|
||||
a non-breaking future extension"; this is that extension. The status
|
||||
parser splits the token on `:`: the base (`ok`/`err`) gives replayability
|
||||
(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
|
||||
unknown future token degrades to "not ok, simple" rather than mis-parsing.
|
||||
|
||||
### Journaling location: the dispatch layer, not the worker
|
||||
|
||||
Both tags are written **at the dispatch layer**, where command + mode +
|
||||
outcome are all in scope — so the mode needs no plumbing into the worker:
|
||||
|
||||
- **Success:** `spawn_dsl_dispatch`, immediately after
|
||||
`execute_command_typed` returns `Ok`, calls
|
||||
`append_history(source, submission_mode.is_advanced())` (best-effort).
|
||||
`run_replay` does the same per replayed line (tagged simple — replay is
|
||||
mode-agnostic), and the app-command sites (`perform_switch` /
|
||||
`spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
|
||||
— app commands run in any mode, so no `:` on recall; this also avoids a
|
||||
redundant `: undo`).
|
||||
- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
|
||||
already at the top), now carrying the mode — `JournalFailure` gains
|
||||
`advanced`, and `DslFailed` gains `submission_mode` for the
|
||||
worker-rejection sub-path (the parse-failure sub-path has it in
|
||||
`dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
|
||||
failure-in-App-path never double-journal.
|
||||
|
||||
The worker's `finalize_persistence` and the four no-op-skip / three
|
||||
read-only sites **no longer journal** — they leave the state writes
|
||||
(yaml/csv) in the worker transaction and let the dispatch layer journal
|
||||
the `Ok` outcome.
|
||||
|
||||
### 4. Hydration reconstructs the `:`-prefixed form
|
||||
|
||||
`read_recent_sources` parses each record's status tag and, for an
|
||||
advanced record, **reconstructs** the `: `-prefixed string from the
|
||||
canonical `source` (`format!(": {source}")`); simple records pass through
|
||||
bare. It still returns `Vec<String>`, so `read_history_seed`,
|
||||
`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
|
||||
hydrated entry is therefore byte-identical to its in-session form, and
|
||||
recall behaves identically.
|
||||
|
||||
### Back-compatibility
|
||||
|
||||
Old `history.log` files have only `ok` / `err` tokens → parsed as
|
||||
`advanced = false` (simple). Their advanced commands stay un-`:`-able on
|
||||
recall — the pre-existing behaviour, not a regression; nothing migrates.
|
||||
`status_is_ok` keys off the base token, so `ok:adv` records replay
|
||||
exactly as `ok` does today (source is canonical either way).
|
||||
|
||||
### Journal write is best-effort (amends ADR-0040)
|
||||
|
||||
Because the journal is now written *after* the worker replies (i.e. after
|
||||
`tx.commit`), a journal-write failure can no longer roll the command back.
|
||||
It is **best-effort** — logged and ignored, exactly like the failure path
|
||||
already is (ADR-0034 §4) — so the two journal paths are finally
|
||||
consistent. State integrity is unchanged: yaml/csv/db still commit
|
||||
atomically in the worker (a *state*-write failure still rolls back and is
|
||||
fatal). The only property given up: on a rare journal-write failure (disk
|
||||
full) a committed command may be missing from `history.log` — not
|
||||
recallable/replayable next session, but the state is correct. User-chosen
|
||||
over keeping journaling coupled in the worker (which would have needed the
|
||||
~30-site mode plumbing). See the plan's §2 for the full trade-off.
|
||||
|
||||
## Forks (user-chosen)
|
||||
|
||||
- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
|
||||
new 4th field (ambiguous with unescaped pipes in old `source`s without
|
||||
a version bump) or a `:`-prefix in `source` (would make `source`
|
||||
non-canonical and force replay to strip it).
|
||||
- **Scope = unified** (bug + feature) over bug-only: one mechanism does
|
||||
both, and keeping `source` canonical for replay needs the mode tag
|
||||
regardless, so bug-only is barely smaller and leaves the main ask open.
|
||||
- **Journaling location = dispatch layer, best-effort** over keeping it
|
||||
worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
|
||||
user's architectural call (§Status).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Advanced history is reusable in simple mode; the `:` one-shot survives
|
||||
resume. The in-memory and on-disk representations agree.
|
||||
- **Journaling left the worker.** `finalize_persistence` and the
|
||||
no-op-skip / read-only sites no longer journal; success is journalled at
|
||||
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
|
||||
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
|
||||
are untouched. The vestigial worker `source` plumbing has since been
|
||||
**fully unwound** (2026-06-14 follow-up): `_source` removed from
|
||||
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
|
||||
`*_request` wrappers inlined and deleted; and — because the cascade ran
|
||||
deeper than first estimated — the now-dead `source` param dropped from
|
||||
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
|
||||
the `source` field removed from the `DescribeTable` / `QueryData` /
|
||||
`RunSelect` requests and the matching `DatabaseHandle` method parameters
|
||||
(the ~164 call-site churn was mostly tests). The only `source` left in
|
||||
the worker is the snapshot/undo label (`snapshot_then` /
|
||||
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
|
||||
Purely mechanical, compiler-guided, no behaviour change.
|
||||
- **App commands recall bare.** Because they are dispatched outside the
|
||||
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
|
||||
false`) at their own sites, and `submit` excludes them from the ring's
|
||||
`advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
|
||||
bare and run fine in simple mode, with no redundant `:`.
|
||||
- **Journaling is now uniform (user-confirmed).** The spawn journals on
|
||||
`outcome.is_ok()`, so **every** successful command is recorded — closing
|
||||
a pre-existing gap where `show table` / `show data` / `select` journalled
|
||||
but `show tables`/`show relationships`/`show indexes`, `show relationship
|
||||
<name>`, and `explain` did **not** (their worker arms carried no
|
||||
`source` / no journal call). The new behaviour matches ADR-0034 §1
|
||||
("record every submitted command"); those reads are now recallable and
|
||||
are re-run harmlessly on replay (`explain` never executes; shows produce
|
||||
output, no state change). A DA finding, accepted as the more-correct
|
||||
behaviour over re-adding command-outcome gating to preserve the old
|
||||
inconsistency.
|
||||
- **Replay re-journaling.** When `replay` re-dispatches a line, the
|
||||
re-written record is tagged from how replay dispatched (mode-agnostic →
|
||||
`ok`), so a replayed advanced command may be re-journalled without
|
||||
`:adv`. Replay correctness of execution is unchanged (it already parses
|
||||
mode-agnostically); this only affects the *tag* of the re-written line.
|
||||
Noted; not addressed here (replay's own mode-fidelity is out of scope).
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
|
||||
submission is stored `: `-prefixed; it recalls as `: …` in simple mode
|
||||
and bare in advanced mode; a simple entry recalls bare in both; a bare
|
||||
`:` is not stored; a parse-failure is still recallable; dedup/cap hold.
|
||||
- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
|
||||
`read_recent_sources` reconstructs the `: `-prefix for `:adv` records
|
||||
and leaves `ok`/`err` records bare (so old logs read as simple);
|
||||
`status_is_ok` is true for `ok` and `ok:adv`.
|
||||
- **Tier-3 (`iteration6_resume_history` / it):** the headline
|
||||
**regression** — type a `:`-one-shot advanced command, journal +
|
||||
hydrate, and assert it recalls **with** `:` in simple mode (fails on
|
||||
current code); plus a persistent-advanced command round-tripping to a
|
||||
`: …` recall.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replay re-journaling mode-fidelity (above).
|
||||
- Special-casing app commands to avoid the redundant recall `: `.
|
||||
- Distinguishing one-shot from persistent advanced on recall (both are
|
||||
simply "advanced" — the `:` is what simple mode needs either way).
|
||||
- A format version marker / pipe-escaping in `source` (unneeded — the
|
||||
status-tag approach keeps `source` last and canonical).
|
||||
@@ -0,0 +1,428 @@
|
||||
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **implemented 2026-06-15** (plan:
|
||||
`docs/plans/20260614-adr-0053-contextual-hint-H2.md`; the F1 keybinding +
|
||||
`hint` command, the `hint_ids` per-form keying + `hint_key_for_input_in_mode`,
|
||||
`last_error_hint_key` + `friendly::error_hint_class`, the `note_hint*`
|
||||
renderers, and the `hint.cmd.*`/`hint.err.*` corpus for every command form
|
||||
+ the 9 runtime error classes, with the comprehensiveness coverage test
|
||||
and the ADR-0051 strip advertising F1). Closes **A1** + requirements
|
||||
**H2**. Deferred: the pre-submit-diagnostic route + `diagnostic.*` blocks
|
||||
(#38), clause-concept hints (#37). Revised after a `/runda` review
|
||||
(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
|
||||
content off `help_id`; split the pre-submit-diagnostic and runtime-error
|
||||
paths; added a comprehensiveness coverage test. Revised again during
|
||||
Phase B implementation (2026-06-15): the first exemplar showed per-*node*
|
||||
keying is too coarse for multi-form commands (`add`/`drop`/`show`/
|
||||
`create`), so D3 now keys tier-3 content **per form** via a
|
||||
`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept
|
||||
hints** are recorded as a deferred extension (issue #37). During Phase C
|
||||
the **pre-submit-diagnostic route + the ~33 `diagnostic.*` blocks** were
|
||||
**deferred** (issue #38) — `Diagnostic` doesn't carry its class key, so
|
||||
the route needs a broad change for marginal value (D6). v1 therefore
|
||||
ships command-form hints + the 9 runtime error-class hints. The parallel
|
||||
question of whether the in-app `help` command should likewise distinguish
|
||||
advanced-SQL forms is tracked **separately** as Gitea issue #36.
|
||||
|
||||
Decided in conversation 2026-06-14. Closes the last open piece of **A1**
|
||||
(the canonical app-command set, ADR-0003): every app command is
|
||||
implemented except `hint`, which ADR-0003's command table listed as
|
||||
*"Request a hint for the current input (ADR pending)."* This ADR is that
|
||||
pending decision. Tracked as **H2** in `docs/requirements.md`.
|
||||
|
||||
References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
|
||||
friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
|
||||
ADR-0022 (ambient typing assistance — colour + hint panel + completion),
|
||||
ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
|
||||
responsive input hint), ADR-0049 (input-field readline keymap), and
|
||||
ADR-0051 (context/state-aware keybinding strip).
|
||||
|
||||
## Context
|
||||
|
||||
`hint` is the only unbuilt app command. The naive reading — "show a hint" —
|
||||
hides a real subtlety, and a real cost.
|
||||
|
||||
**The subtlety: a submitted `hint` command cannot see live input.** App
|
||||
commands are submitted with Enter, which empties the input buffer. By the
|
||||
time `hint` dispatches, the partial command it was meant to help with is
|
||||
gone. So "a hint for the current input" cannot be served by a submitted
|
||||
command alone — it needs a *keybinding* that acts on the live buffer
|
||||
without submitting. ADR-0003 said "current input"; `requirements.md`
|
||||
broadened it to "current input **or the most recent error**." Both are
|
||||
wanted; they map to two different trigger surfaces.
|
||||
|
||||
**The cost: the value of `hint` is content, not plumbing.** The app
|
||||
already carries two tiers of contextual text:
|
||||
|
||||
- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
|
||||
*headline* alone (ADR-0019, when `messages_verbosity: Short`).
|
||||
- **Tier 2** — short contextual lines: the ambient typing prose /
|
||||
`expected` set, shown live while typing (ADR-0022, catalogue
|
||||
`hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
|
||||
which, because `Verbosity::Verbose` is the **default**
|
||||
(`src/friendly/translate.rs:46`), is shown **by default** beneath every
|
||||
error headline (`messages short` is the opt-*out*, not `messages
|
||||
verbose` the opt-in).
|
||||
|
||||
So the verbose error hint is **already on screen by default**. If `hint`
|
||||
merely re-showed it, it would duplicate what the user can already see (and
|
||||
the ambient panel). To justify itself, `hint` must add a **tier 3**: a
|
||||
genuinely deeper, *teaching*-grade explanation — what the command/error
|
||||
means, a worked example, and the underlying relational concept. That
|
||||
corpus does not exist yet, and
|
||||
authoring it (to the standard of a teaching tool, where "pedagogy wins
|
||||
ties") is the bulk of the work.
|
||||
|
||||
The mechanism is small and reuses everything already present: the command
|
||||
REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
|
||||
(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
|
||||
`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
|
||||
(`src/app.rs:2982`/`3021`), the parser/walker expected-set
|
||||
(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
|
||||
catalogue + `t!` macro + `keys.rs` validation, and the output styling
|
||||
vocabulary (`OutputStyleClass::Hint`).
|
||||
|
||||
## Decision
|
||||
|
||||
### D1 — Two surfaces, no topic argument
|
||||
|
||||
`hint` is delivered through **two complementary surfaces**:
|
||||
|
||||
1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
|
||||
tier-3 hint for the command currently in the buffer, into the output
|
||||
panel, **without submitting or altering the buffer**. This is the
|
||||
primary, most-valuable path (it serves the literal "current input").
|
||||
2. **`hint` command → most recent error.** Submitting `hint` renders the
|
||||
tier-3 expansion of the most recent error. This is why the command
|
||||
exists despite the empty-buffer problem: the thing it helps with is
|
||||
the *last thing you tried*, not the now-empty buffer.
|
||||
|
||||
`hint` takes **no topic argument**. Explicit per-command reference is
|
||||
already `help <topic>` (H3); `hint` is purely *contextual*, which keeps
|
||||
the two cleanly distinct (`hint` = "help me with what I'm doing right
|
||||
now"; `help insert` = "show me the insert reference").
|
||||
|
||||
F1 is a **read-only overlay**: it never alters the input buffer, the
|
||||
cursor, or the live completion memo (ADR-0022) — it only emits a block
|
||||
into the output journal. (It must therefore be handled in `handle_key`
|
||||
*before* the "any other key clears the memo" fall-through.)
|
||||
|
||||
### D2 — Trigger matrix
|
||||
|
||||
| Trigger | Buffer / state | Result |
|
||||
|---|---|---|
|
||||
| **F1** | non-empty input | tier-3 hint for the command being typed. (No "expected next" line — the always-on tier-2 ambient panel already shows it live; tier-2 owns position-awareness.) |
|
||||
| **F1** | empty input, a recent error exists | tier-3 expansion of that error |
|
||||
| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) |
|
||||
| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) |
|
||||
| **`hint`** (submitted) | no recent error | the same "getting started" pointer |
|
||||
|
||||
F1 is inert behind a modal and while a sidebar panel holds navigation
|
||||
focus (consistent with the existing `handle_key` gates, ADR-0046); it is
|
||||
active in the input context in both Simple and Advanced mode.
|
||||
|
||||
**Error routes.** **Runtime errors** (the 9 `translate_error` classes)
|
||||
occur *after* submit; the **`hint` command / empty-input F1** path reads
|
||||
them via the stored `last_error_hint_key` (D5) and renders their
|
||||
`hint.err.<class>` block. (A second route for **pre-submit diagnostics**
|
||||
on the F1 live-input path was specified but is **deferred** — D6 / issue
|
||||
#38; with a diagnostic present, F1 shows the command block and tier-2
|
||||
shows the diagnostic.) **`:`-prefix handling:**
|
||||
on the simple-mode one-shot escape (`: SELECT …`), command
|
||||
identification for the F1 path strips the leading `:` first, so the
|
||||
advanced form is matched.
|
||||
|
||||
### D3 — The tier-3 content model
|
||||
|
||||
Tier-3 blocks live in the friendly catalogue under the existing `hint:`
|
||||
top-level namespace (where tier-2 ambient strings already live), in two
|
||||
new sub-namespaces:
|
||||
|
||||
- **`hint.cmd.<hint_id>`** — one per command **form**, keyed by a **new
|
||||
`hint_ids: &'static [&'static str]`** field on `CommandNode`
|
||||
(`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**.
|
||||
The F1 live-input path resolves the current input to its form's hint key
|
||||
via `hint_key_for_input_in_mode`, which reuses the same form-word
|
||||
disambiguation as `usage_key_for_input_in_mode`.
|
||||
|
||||
**Why an array mirroring `usage_ids`, not a per-node `hint_id`**
|
||||
*(`/runda`/implementation revision, 2026-06-15)*: a single per-node key
|
||||
is too coarse. Several entry words are **one node spanning many forms** —
|
||||
`add` (column/relationship/index/constraint), `drop` (table/column/
|
||||
relationship/index), `show` (data/table/tables/relationships/indexes),
|
||||
`create` (table/index). A live-input hint for `add 1:n relationship` is
|
||||
only useful if it is *specific to relationships*, so the content must be
|
||||
**per form**, not per node. The project already solved exactly this for
|
||||
usage templates (`usage_ids` is a per-form array, disambiguated by the
|
||||
form word), so `hint_ids` mirrors it. Single-form nodes carry one entry;
|
||||
multi-form nodes carry one per form. This also covers the advanced-SQL
|
||||
forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`,
|
||||
`EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of
|
||||
usage, with mode-correct SQL examples. (The `help`-list collapse of
|
||||
advanced-SQL forms is a separate gap — issue #36.)
|
||||
|
||||
**Deferred extension — clause-concept hints** (issue #37): per-form is
|
||||
the right granularity for tier-3 *teaching* (position-awareness within a
|
||||
form is owned by tier-2 ambient + the live `Next:` line, D4). But some
|
||||
**concepts live inside a clause**, not a form — `… on delete ⟨cascade|
|
||||
set null|restrict⟩` (referential actions), the `create table` constraint
|
||||
slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n`
|
||||
cardinality. A learner parked in such a clause may want teaching deeper
|
||||
than tier-2's candidate list but narrower than the whole-form block. v1
|
||||
does **not** build this (it would multiply content for points whose value
|
||||
we can't yet measure, and we don't expect to accumulate usage statistics
|
||||
to drive it empirically — it will be tackled as a deliberate follow-up
|
||||
job). The keying does not lock it out: a later `hint.concept.<topic>`
|
||||
namespace can be surfaced when the cursor sits in a recognized clause,
|
||||
layered on top of the per-form block.
|
||||
- **`hint.err.<class>`** — one per error/diagnostic class, keyed by the
|
||||
friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`,
|
||||
`hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by
|
||||
both error routes (D2).
|
||||
|
||||
Each tier-3 block is a **structured entry with three labelled parts**, so
|
||||
the voice stays consistent and the renderer can style them uniformly:
|
||||
|
||||
```yaml
|
||||
hint.cmd.dsl.insert:
|
||||
what: "Add one or more rows to a table."
|
||||
example: "insert into Customers values ('Ann', 'ann@x.io')"
|
||||
concept: "A row is one record; each value lines up with a column, in
|
||||
order. Columns typed `serial`/`shortid` fill themselves — leave them out."
|
||||
```
|
||||
|
||||
- **`what`** — one or two plain sentences: what this command does / what
|
||||
this error means.
|
||||
- **`example`** — a single concrete, copyable line (rendered neutral, not
|
||||
muted, so it stands out as runnable).
|
||||
- **`concept`** — the underlying relational idea, in teaching voice; the
|
||||
part that makes this tier-3 rather than tier-2.
|
||||
|
||||
`concept` is optional where there is genuinely no concept beyond the
|
||||
mechanics (e.g. `quit`); `what` + `example` are always present.
|
||||
|
||||
### D4 — Rendering
|
||||
|
||||
Both surfaces render through the `App::note_hint*` family (sibling of
|
||||
`note_help`/`note_help_topic`, `src/app.rs`) via `emit_tier3_block`,
|
||||
emitting into the `output` buffer as `OutputKind::System`: a **`Hint`
|
||||
heading** followed by aligned **`What:` / `Example:` / `Concept:`** lines
|
||||
(labels + heading from `hint.block.*`). The `concept` line is muted
|
||||
(`OutputStyleClass::Hint`); the rest are plain. The block is
|
||||
**persistent** (scrolls in the journal), unlike the transient ambient
|
||||
panel — pressing F1 is an explicit request to *keep* the deeper guidance
|
||||
on screen. Its rendered shape is locked by an `insta` snapshot
|
||||
(`hint_block_insert`). The bottom keybinding strip (ADR-0051) advertises
|
||||
F1 in the editing (leading) and default states.
|
||||
|
||||
### D5 — "Most recent (runtime) error" state
|
||||
|
||||
The **runtime-error route** (submitted `hint`, and empty-input F1) needs
|
||||
to map the last runtime error back to its `hint.err.<class>` key. Runtime
|
||||
errors today live only as rendered text in the `output` buffer. We add a
|
||||
single small piece of `App` state — **`last_error_hint_key:
|
||||
Option<String>`** — set at the `translate_error` call sites
|
||||
(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered,
|
||||
cleared when a later command succeeds. Absent → the "getting started"
|
||||
pointer.
|
||||
|
||||
The **pre-submit-diagnostic route** (the F1 live-input path reading the
|
||||
under-cursor diagnostic) is **deferred** — see the scope note in D6.
|
||||
|
||||
### D6 — Content scope for v1
|
||||
|
||||
v1 ships tier-3 content for the **command forms and runtime error
|
||||
classes** — comprehensive for those (the graceful tier-2 fallback below
|
||||
is a safety net, not the plan):
|
||||
|
||||
- **~37 command forms** — every distinct node in `REGISTRY` gets its own
|
||||
`hint.cmd.<hint_id>` block (app + DSL + DDL + advanced-mode SQL forms),
|
||||
each with a **mode-correct example** (the advanced-SQL forms show SQL
|
||||
syntax, their simple siblings show DSL — no sharing).
|
||||
- **9 runtime error classes** — `unique`, `foreign_key` (child/parent
|
||||
side), `not_null`, `check`, `type_mismatch`, `not_found`,
|
||||
`already_exists`, `generic`, `invalid_value` — each gets a
|
||||
`hint.err.*` block.
|
||||
|
||||
**Deferred — the ~33 `diagnostic.*` pre-submit classes and the F1
|
||||
diagnostic route** *(Phase C scope decision, 2026-06-15; issue #38)*. The
|
||||
original "comprehensive" scope included them, but implementation revealed
|
||||
`Diagnostic` (`walker/outcome.rs`) carries only its rendered `message`,
|
||||
not its class key — so a live diagnostic can't be mapped to
|
||||
`hint.err.<class>` without adding a `class` field threaded through every
|
||||
diagnostic-creation site (a broad change). Weighed against the value, it
|
||||
isn't worth it for v1: pre-submit diagnostics are already surfaced by
|
||||
tier-2 (ambient message + validity indicator, ADR-0027); F1 still shows
|
||||
the useful command block when a diagnostic is present; and many
|
||||
diagnostic classes duplicate runtime classes already covered
|
||||
(`type_mismatch`, `unknown_table`↔`not_found`, arity↔`invalid_value`).
|
||||
Deferred to issue #38, additively (the keying doesn't lock it out).
|
||||
|
||||
The full enumerated checklist is the implementation plan's tracking
|
||||
artifact (see *Content inventory*, below).
|
||||
|
||||
**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
|
||||
the surface degrades to tier 2 — the ambient prose for the command path,
|
||||
or the verbose error `hint:` for the error path — never to a blank or an
|
||||
error. The `keys.rs` build-time validation keeps the corpus honest, so a
|
||||
missing key is caught in tests, not in front of a student.
|
||||
|
||||
### D7 — Authoring process: exemplars-first
|
||||
|
||||
Because the corpus is large and its *voice* is a pedagogical decision the
|
||||
maintainer owns, content is produced in two stages:
|
||||
|
||||
1. This ADR carries **2–3 worked exemplars** (below) as the canonical
|
||||
style reference. The `/runda` review of this ADR is where the voice and
|
||||
depth are approved.
|
||||
2. Once approved, the remaining blocks are authored to that template in
|
||||
**reviewable batches** (grouped by area: DDL, DML, app commands,
|
||||
error classes), not one monolithic drop.
|
||||
|
||||
### Exemplars (the style reference; shipped as the rendered format)
|
||||
|
||||
**Command (F1 live-input), `insert`** (the rendered shape, locked by the
|
||||
`hint_block_insert` snapshot — a `Hint` heading + aligned labels, no
|
||||
`Next:` line since tier-2 owns position-awareness):
|
||||
|
||||
```
|
||||
Hint
|
||||
What: Add one or more rows to a table.
|
||||
Example: insert into Customers values ('Ann', 'ann@example.io')
|
||||
Concept: A row is one record; each value lines up with a column, in
|
||||
order. Columns typed serial/shortid fill themselves — leave
|
||||
them out.
|
||||
```
|
||||
|
||||
**Error (`hint` command), foreign-key child-side violation:**
|
||||
|
||||
```
|
||||
Hint
|
||||
What: The value you gave for the child column doesn't match any
|
||||
parent row, so the foreign key has nothing to point at.
|
||||
Example: First insert the parent (insert into Customers …), then the
|
||||
child that references it.
|
||||
Concept: A foreign key is a promise that every child points at a real
|
||||
parent, so the parent must exist first. To allow orphans on
|
||||
delete instead, set the relationship's `on delete` to
|
||||
`set null` or `cascade`.
|
||||
```
|
||||
|
||||
**Command (F1 live-input), `add 1:n relationship`:**
|
||||
|
||||
```
|
||||
Hint
|
||||
What: Link two tables so a parent row can own many child rows.
|
||||
Example: add 1:n relationship from Customers.id to Orders.customer_id
|
||||
Concept: The "1:n" means one parent, many children. The child column
|
||||
holds the foreign key; `--create-fk` adds it for you if it
|
||||
doesn't exist yet.
|
||||
```
|
||||
|
||||
## Forks (all user-chosen, 2026-06-14)
|
||||
|
||||
- **Trigger model:** both a keybinding (live input) and a submitted
|
||||
command (last error), rather than command-only or keybinding-only — the
|
||||
live-input path is the most useful, but the command completes the A1
|
||||
slot and serves the error case.
|
||||
- **Keybinding = F1:** the universal help convention; the key is
|
||||
genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
|
||||
strings in `input_render.rs`/tests are scenario labels, not the key, and
|
||||
ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
|
||||
readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
|
||||
`Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
|
||||
position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
|
||||
advantage).
|
||||
- **No topic argument:** contextual only; `help <topic>` already owns
|
||||
explicit reference lookup.
|
||||
- **Comprehensive content for v1:** the full inventory, not a starter
|
||||
subset.
|
||||
- **Exemplars-first authoring:** lock the voice on a few blocks, then
|
||||
mass-author to template.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A1 closes.** With `hint` registered and built, all 15 canonical
|
||||
app-level commands exist in both modes.
|
||||
- **A third contextual tier exists.** Students get on-demand, teaching-
|
||||
grade guidance that is deeper than the always-on colour, the headline,
|
||||
the ambient one-liner, and the verbose error hint — without cluttering
|
||||
those terse defaults.
|
||||
- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip.
|
||||
- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring
|
||||
`usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the
|
||||
`usage_key_for_input_in_mode` form-disambiguation), one new field of
|
||||
`App` state (`last_error_hint_key`), and one new renderer family
|
||||
(`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT`
|
||||
node, the REGISTRY one entry.
|
||||
- **A durable content corpus** (~37 command blocks + 10 runtime
|
||||
error-class blocks) enters the catalogue under `hint.cmd.*` /
|
||||
`hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new
|
||||
commands/error classes should ship with their tier-3 hint (a checklist
|
||||
item for future feature ADRs). (Diagnostic-class blocks deferred — #38.)
|
||||
- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with
|
||||
empty/non-empty input; `hint` with/without a recent error;
|
||||
`last_error_hint_key` set on the `translate_error` sites and cleared on
|
||||
success; the mode-aware form resolution; the `:` strip), the
|
||||
command-identification logic, and the tier-2 fallback; Tier-2 `insta`
|
||||
snapshots for a representative rendered hint block; Tier-3 integration
|
||||
tests for the end-to-end flows (type a partial command → F1 → block
|
||||
appears, **buffer and completion memo untouched**; run a failing
|
||||
command → `hint` → error expansion). **A comprehensiveness coverage
|
||||
test** (enforces D6): iterate the REGISTRY and assert every node with a
|
||||
`hint_ids` entry resolves to a `hint.cmd.*` block, and every runtime
|
||||
error class resolves to a `hint.err.*` block — `keys.rs` only checks
|
||||
that *referenced* keys resolve, not that every command/error *has* one,
|
||||
so this test is what makes the scope enforceable rather than
|
||||
aspirational. (Diagnostic classes are out of this scope — D6 / #38.)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Per-topic `hint <topic>`** — OOS (rejected): `help <topic>` already
|
||||
serves explicit lookup; a topic arg would overlap it and double the
|
||||
content-authoring surface.
|
||||
- **Re-showing tier-3 inline as the always-on ambient hint** — OOS
|
||||
(rejected): the ambient panel stays terse by design (ADR-0022); tier-3
|
||||
is on-demand. Promoting it would defeat the tiering.
|
||||
- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the
|
||||
catalogue is structured for i18n (ADR-0019), but additional locales
|
||||
follow the project's English-only-for-v1 stance (requirements X2).
|
||||
- **`hint` for a *successful* command's deeper teaching** (e.g. "you just
|
||||
created a table — here's what an index would add") — OOS (deferred): a
|
||||
plausible future tier-3 use, but v1 scopes the command path to errors
|
||||
and the F1 path to in-progress input.
|
||||
- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots,
|
||||
`with pk`, cardinality) — OOS (deferred, issue #37): a
|
||||
`hint.concept.<topic>` layer surfaced when the cursor sits in a
|
||||
recognized clause, deeper than tier-2's candidate list but narrower than
|
||||
the per-form block. Per-form keying (D3) does not lock it out. To be
|
||||
tackled as a deliberate follow-up job, not gated on usage statistics.
|
||||
- **Pre-submit-diagnostic route + `diagnostic.*` tier-3 blocks** — OOS
|
||||
(deferred, issue #38): needs a class field on `Diagnostic` threaded
|
||||
through every creation site (broad change) for marginal value, since
|
||||
tier-2 already surfaces diagnostics and many duplicate runtime classes
|
||||
(D6).
|
||||
|
||||
## Content inventory (implementation tracking)
|
||||
|
||||
The implementation plan enumerates and checks off every block:
|
||||
|
||||
- **`hint.cmd.<hint_id>`** — one per distinct `REGISTRY` node (~37), each
|
||||
with its own `hint_id` and a mode-correct example: app (`save`, `save
|
||||
as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
|
||||
`redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL
|
||||
(`create table`, `create m:n`, `add column`/`relationship`/`index`,
|
||||
`drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`,
|
||||
`show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL
|
||||
forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP
|
||||
TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`)
|
||||
each get their **own** block with SQL syntax — they do **not** reuse
|
||||
their simple sibling's (this is the `/runda` correction; the parallel
|
||||
`help`-side gap is issue #36).
|
||||
- **`hint.err.*`** — one per runtime error class (`unique`,
|
||||
`foreign_key.{child,parent}_side`, `not_null`, `check`,
|
||||
`type_mismatch`, `not_found`, `already_exists`, `generic`,
|
||||
`invalid_value`). The `diagnostic.*` pre-submit classes are **deferred**
|
||||
(D6 / issue #38).
|
||||
@@ -0,0 +1,73 @@
|
||||
# ADR-0054: Release versioning policy + version surfaces (`--version` / `version`)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **implemented 2026-06-16** (plan:
|
||||
`docs/plans/20260616-public-availability.md`, step 1). First step on the
|
||||
road to public availability. Adds a `--version` / `-V` CLI flag and an
|
||||
in-app `version` command, both reporting `CARGO_PKG_VERSION`, plus a
|
||||
release-CI guard that the `v*` tag equals that version. No prior issue or
|
||||
`requirements.md` item existed for this — it was an untracked gap.
|
||||
|
||||
## Context
|
||||
|
||||
Before this, `Cargo.toml` carried `version = "0.1.0"`, the binary exposed
|
||||
**no** way to report its version, and `release.yaml` named assets from the
|
||||
**git tag** (`rdbms-playground-<tag>-<target>`) while building from
|
||||
`Cargo.toml`. Tag and crate version were **decoupled**: tagging `v0.2.0`
|
||||
would publish an asset named `…-v0.2.0-…` containing a binary that (had it
|
||||
been able to say) reported `0.1.0`. On the way to public availability —
|
||||
where users download a versioned artifact and file bug reports against "the
|
||||
version I'm running" — that drift is a correctness problem.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **`Cargo.toml` `version` is the single source of truth.** This is the
|
||||
idiomatic Rust position and avoids making `Cargo.toml` lie. The version
|
||||
is read at compile time via `env!("CARGO_PKG_VERSION")`; no build-time
|
||||
injection of the tag into the crate.
|
||||
|
||||
2. **Two user-facing surfaces, one source:**
|
||||
- **`--version` / `-V`** — CLI flag (hand-rolled parser, mirrors
|
||||
`--help`): prints and exits before any other work (`main.rs`).
|
||||
- **`version`** — an in-app app command (REGISTRY node `app::VERSION`,
|
||||
`AppCommand::Version`), emitting the same line into the output panel.
|
||||
Both go through `cli::version_text()` → the catalog key
|
||||
`cli.version_line` (`"rdbms-playground {version}"`), so there is exactly
|
||||
one rendered string and one version source.
|
||||
|
||||
3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a
|
||||
**version guard**: it reads the `[package]` version directly from
|
||||
`Cargo.toml` (`grep -m1 '^version = '` — toolchain-free; an earlier
|
||||
`cargo metadata | node` form broke on the flake devShell's stdout banner)
|
||||
and **fails the release** unless the pushed tag equals `v<that version>`.
|
||||
So `--version`, the release name, and the downloaded asset are always in
|
||||
lockstep — enforced by the machine, not by memory.
|
||||
|
||||
4. **The release ritual:** bump `Cargo.toml` → commit → tag `v<that
|
||||
number>` → push the tag. The guard rejects any deviation.
|
||||
|
||||
### Rejected / deferred
|
||||
- **Inject the tag into the build** (tag as source of truth): fiddly with
|
||||
cargo and makes `Cargo.toml` a placeholder/lie. Rejected.
|
||||
- **Git-hash + build-date enrichment** (a `build.rs` so dev builds read
|
||||
`0.2.0 (a1b2c3d)`): useful for bug reports, but not needed for the
|
||||
tag↔release↔`--version` consistency this ADR is about. Deferred; can be
|
||||
added behind the same `version_text()` seam without changing the policy.
|
||||
- **UI placement beyond the `version` command** (status-bar string, etc.):
|
||||
the command + `help` listing is enough for now (user decision).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A release can no longer ship a binary whose self-reported version
|
||||
disagrees with its tag/filename.
|
||||
- Cutting a release now *requires* a `Cargo.toml` bump commit — a small,
|
||||
deliberate step (and a natural place to update a changelog later).
|
||||
- New keys: `cli.version_line` (+ `help.app.version`, `parse.usage.version`,
|
||||
`hint.cmd.version.what`/`.example`); a new REGISTRY command means the
|
||||
comprehensiveness coverage test now also requires `hint.cmd.version`,
|
||||
which is supplied. Tested: CLI flag parse (`--version`/`-V`/default-off),
|
||||
`version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses to
|
||||
`AppCommand::Version` and emits the version.
|
||||
- This is step 1 of `docs/plans/20260616-public-availability.md`; the
|
||||
installer (`install.sh`) and package-manager work (D3) build on top.
|
||||
@@ -0,0 +1,86 @@
|
||||
# ADR-0055: `curl | sh` install script (`scripts/install.sh`)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **implemented 2026-06-17** (plan:
|
||||
`docs/plans/20260616-public-availability.md`, step 2). Step 2 on the road
|
||||
to public availability, building on ADR-0054 (versioned releases) and
|
||||
ADR-ci-001/003 (the Gitea releases it downloads from). Tracked by the
|
||||
plan + this ADR (no Gitea issue — user decision, 2026-06-17).
|
||||
|
||||
## Context
|
||||
|
||||
Until now, installing meant: find the releases page, work out which of
|
||||
the six assets matches your machine, download it, `chmod +x`, move it
|
||||
onto `PATH`, and (on macOS) wonder about Gatekeeper. That is too much
|
||||
friction for a teaching tool aimed at beginners. The Gitea releases are
|
||||
**publicly downloadable** (confirmed), with deterministic asset names
|
||||
(`rdbms-playground-<tag>-<target>[.exe]`) and `.sha256` sidecars
|
||||
(ADR-ci-003), and a `releases/latest` API — enough to script a one-liner
|
||||
install.
|
||||
|
||||
## Decision
|
||||
|
||||
Ship **`scripts/install.sh`**, run as
|
||||
`curl -fsSL <gitea-raw>/scripts/install.sh | sh`:
|
||||
|
||||
- **POSIX `sh`** (no bashisms) — it runs under the `sh` of `curl | sh`;
|
||||
kept **shellcheck-clean** (`-s sh`).
|
||||
- **Platform detection** from `uname` → target triple: Linux →
|
||||
`<arch>-unknown-linux-musl` (the fully-static build — one universal
|
||||
Linux artifact, no glibc/version coupling), macOS → `<arch>-apple-darwin`;
|
||||
`x86_64`/`amd64` and `aarch64`/`arm64` both map. **Windows is rejected**
|
||||
with a pointer to Scoop/winget/the releases page (the binary is a `.exe`,
|
||||
not a `curl|sh` target).
|
||||
- **Version:** the `releases/latest` API tag by default; `RDBMS_VERSION`
|
||||
pins a specific tag.
|
||||
- **Integrity:** always download the `.sha256` sidecar and **verify**
|
||||
(`sha256sum`/`shasum -a 256`); a mismatch aborts the install. HTTPS only.
|
||||
- **Install location:** `~/.local/bin` by default (user-writable, no
|
||||
sudo), overridable via `RDBMS_INSTALL_DIR`; prints a PATH hint if the
|
||||
dir isn't on `PATH`.
|
||||
- **macOS note:** a `curl` download is **not** Gatekeeper-quarantined, so
|
||||
the binary runs as-is even while it is only ad-hoc-signed; proper
|
||||
Developer-ID signing + notarization (for *browser* downloads) is a
|
||||
separate, postponed task (see the plan's signing item).
|
||||
- **Testing seams:** `RDBMS_OS`/`RDBMS_ARCH` force detection and
|
||||
`--print-target` prints the resolved triple and exits — so the mapping
|
||||
is checkable without a download.
|
||||
|
||||
### Rejected / deferred
|
||||
- **Hosting the script on the website domain** (Cloudflare): nicer URL,
|
||||
but adds a moving part; the **Gitea repo raw URL** is simplest and the
|
||||
binaries live there anyway (user decision). The website may later
|
||||
*reference* the same command.
|
||||
- **Uploading `install.sh` as a release asset** for a stable link:
|
||||
optional; the branch raw URL is fine for now.
|
||||
|
||||
## Amendment 1 — `install.ps1` (Windows) added (2026-06-17)
|
||||
|
||||
Windows was originally deferred to Scoop/winget; the user opted for **both**
|
||||
a PowerShell one-liner now *and* package managers later. Added
|
||||
**`scripts/install.ps1`** (`irm <url> | iex`): maps the host CPU to our
|
||||
`*-windows-gnu`/`-gnullvm` `.exe`, resolves the latest release (or
|
||||
`-Version`/`RDBMS_VERSION`), downloads + **SHA-256-verifies**, installs to
|
||||
`%LOCALAPPDATA%\Programs\rdbms-playground` (`-InstallDir`/`RDBMS_INSTALL_DIR`
|
||||
override), and adds that dir to the **user PATH**. **Caveat:** unlike
|
||||
`install.sh` (verified end-to-end), this was **written but not tested from
|
||||
this environment** (no PowerShell available) — validate on a real Windows
|
||||
host. Scoop/winget (D3) remain the idiomatic package-manager routes.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A first-time user runs one line and gets a checksum-verified binary on
|
||||
`PATH`. The website's install copy (website branch, separate agent) can
|
||||
point at this command.
|
||||
- **Verified end-to-end** (2026-06-17) against the live public `v0.1.0`:
|
||||
all four Linux/macOS platform mappings + Windows/unknown-arch rejection;
|
||||
pinned and latest paths; checksum verification incl. a tamper-rejection
|
||||
check; install + run on Linux x86_64. (The installed `v0.1.0` predates
|
||||
`--version`, ADR-0054 — a non-issue, and the reason to cut a new
|
||||
release.)
|
||||
- **No automated regression guard in CI yet:** shellcheck isn't in the
|
||||
flake, and there's no shell-test harness here (no bats). Recommended
|
||||
follow-up: add a `shellcheck scripts/*.sh` gate (touches ADR-ci-002 —
|
||||
needs shellcheck in the devShell). For now the guard is local
|
||||
shellcheck + the documented end-to-end verification.
|
||||
@@ -0,0 +1,88 @@
|
||||
# ADR-0056: crates.io publish-readiness + `cargo binstall` metadata (D3)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **prepared 2026-06-17** (plan:
|
||||
`docs/plans/20260616-public-availability.md`, step 3a). The crate is made
|
||||
**ready to publish** and carries `cargo-binstall` metadata. The actual
|
||||
`cargo publish` is a gated maintainer step (see Ordering). First D3
|
||||
package-manager mechanism; builds on ADR-0054 (versioned releases),
|
||||
ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR
|
||||
(no Gitea issue — user decision).
|
||||
|
||||
## Context
|
||||
|
||||
`cargo binstall rdbms-playground` (and `cargo install`) need the crate on
|
||||
**crates.io** (user decision, 2026-06-17). The manifest had
|
||||
`publish = false`, a `readme = "README.md"` pointing at a **missing**
|
||||
file, and no `keywords`/`categories`. Our release assets are **bare
|
||||
binaries** (not archives) named `rdbms-playground-v<version>-<target>`
|
||||
(`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003); critically the
|
||||
**release target triples differ from users' host triples** — we ship the
|
||||
static `*-linux-musl` build (hosts are `*-linux-gnu`) and
|
||||
`*-windows-gnu`/`-gnullvm` (hosts are `*-msvc`); only macOS matches.
|
||||
|
||||
## Decision
|
||||
|
||||
**Publish-readiness (this change):**
|
||||
- Drop `publish = false`; add `homepage = "https://relplay.org"`,
|
||||
`keywords`, `categories = ["command-line-utilities", "database"]`, and
|
||||
an `exclude` (`/website`, `/docs`, `/.gitea`, `/.codegraph`) so the
|
||||
published crate is code-only (585 files/8.3 MiB → 353/913 KiB
|
||||
compressed).
|
||||
- Author **`README.md`** (the `readme` target + crates.io front page;
|
||||
engine-neutral and "simple/advanced mode" wording per ADR-0002 / the
|
||||
website copy rules), with install instructions (curl|sh, binstall,
|
||||
source, prebuilt).
|
||||
- Add **`LICENSE-MIT`** and **`LICENSE-APACHE`** (the latter the verbatim
|
||||
canonical text, added by the maintainer; both © Lazy Evaluation Ltd —
|
||||
the publication entity), and a **`CONTRIBUTING.md`** stating the
|
||||
"inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes
|
||||
the §3 patent grant explicit on the self-hosted forge). Dual license
|
||||
kept (not MIT-only) — user decision after reviewing the patent-grant
|
||||
rationale.
|
||||
|
||||
**`cargo binstall` metadata** (`[package.metadata.binstall]`, syntax
|
||||
verified against cargo-binstall SUPPORT.md):
|
||||
- `pkg-fmt = "bin"` (bare binary), `bin-dir = "{ bin }{ binary-ext }"`,
|
||||
and a base `pkg-url` using `v{ version }` (the `{ version }` placeholder
|
||||
excludes the leading `v`).
|
||||
- **Per-target `overrides`** mapping the common host triples to the asset
|
||||
we actually publish: `x86_64`/`aarch64-unknown-linux-gnu` → the `-musl`
|
||||
asset; `x86_64`/`aarch64-pc-windows-msvc` → the `-gnu`/`-gnullvm`
|
||||
`.exe`. macOS needs no override (host triple == asset triple). The docs
|
||||
do **not** promise automatic musl/gnu or msvc/gnu fallback, hence
|
||||
explicit overrides.
|
||||
|
||||
**Ordering / gating (important):**
|
||||
- `cargo publish` is **irreversible** (needs the crates.io token; a
|
||||
version can't be un-published, only yanked) — a deliberate **maintainer
|
||||
step**, not done here.
|
||||
- binstall's `pkg-url` resolves to a **tagged release's** assets, so
|
||||
publish **at a new tagged version whose release already exists**, and
|
||||
publish **after** that release is built. **Do not publish `0.1.0`** — it
|
||||
would diverge from the already-released `0.1.0` binaries (which predate
|
||||
`--version`, ADR-0054). The clean path: bump → tag → release builds →
|
||||
`cargo publish`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo publish --dry-run --allow-dirty` packages + verify-builds cleanly
|
||||
(353 files, 913 KiB compressed; no metadata errors).
|
||||
- `cargo metadata` confirms the `binstall` block + all four `overrides`
|
||||
parse.
|
||||
- **Unverified:** an actual `cargo binstall` run — cargo-binstall isn't a
|
||||
dependency and nothing is on crates.io yet. **Validate at the first
|
||||
publish + matching release** (especially the windows-msvc→gnu and
|
||||
linux-gnu→musl overrides).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The crate can be published at the next tagged release with `cargo
|
||||
publish` (+ the token); `cargo install rdbms-playground` and `cargo
|
||||
binstall rdbms-playground` then work.
|
||||
- Remaining D3: Scoop, Homebrew (`lazyeval` tap), winget (komac/manual) —
|
||||
each a manifest + a per-release bump, tracked in the plan.
|
||||
- Remaining follow-up: run the real `cargo binstall` validation at the
|
||||
first publish + matching release (the license files, © holder, and
|
||||
CONTRIBUTING are now in place).
|
||||
+19
-4
File diff suppressed because one or more lines are too long
@@ -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**.
|
||||
|
||||
@@ -20,6 +20,17 @@ This ADR records the **cross-platform build strategy**; it sits on top of
|
||||
**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and
|
||||
**ADR-ci-001** (the pipeline, whose release job this fills in).
|
||||
|
||||
## Amendment 2 — 2026-06-16: CI on `main`; `release-macos` dispatched + verified
|
||||
|
||||
The CI branch is **merged to `main`**, so `release-macos.yaml`
|
||||
(`workflow_dispatch`, Gitea-default-branch-only) is now triggerable. It
|
||||
has been **dispatched and verified end-to-end**: both `*-apple-darwin`
|
||||
targets build on the Tart runner, the de-nix/re-sign step runs, the
|
||||
assets upload to the tagged release, and the binaries launch. macOS is
|
||||
therefore runtime-verified (alongside the original Linux x86_64 +
|
||||
Windows aarch64); only **Linux aarch64** and **Windows x86_64** remain
|
||||
link-clean / valid-format without a runtime smoke-test.
|
||||
|
||||
## Amendment — 2026-06-14: macOS implemented (closes D1)
|
||||
|
||||
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
|
||||
|
||||
@@ -19,5 +19,5 @@ here too).
|
||||
## Index
|
||||
|
||||
- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision).
|
||||
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible).
|
||||
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
|
||||
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). **Amendment 1 (2026-06-17, issue #35):** the deferred `fmt` gate is enabled — the tree was reformatted once with **stock `cargo fmt`** (no `rustfmt.toml`; pinned toolchain makes `fmt --check` deterministic) in a single mechanical commit (`41b7e9a`, 102 files, behaviour-preserving, in `.git-blame-ignore-revs`), and `ci.yaml`'s gate is now **`fmt --check` + clippy + test**.
|
||||
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). **Amendment 2 (2026-06-16):** CI is **merged to `main`**, so `release-macos` is now triggerable (`workflow_dispatch` is default-branch-only) and has been **dispatched + verified end-to-end** (build → de-nix/re-sign → upload, binaries launch). Runtime-verified by the user: Linux x86_64, Windows aarch64, **and both macOS targets**; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# CI subproject handoff — 2026-06-15 (ci-01)
|
||||
|
||||
First handover for the **CI / release subproject** (the `ci` branch). Kept in
|
||||
`docs/ci/handoff/`, a namespace separate from the project's global
|
||||
`docs/handoff/` session sequence so it can't collide with `main`'s numbering —
|
||||
the same split as `docs/ci/adr/`, and needed for the same reason: `main`
|
||||
independently wrote its own **handoff-70** this same day (just as it took
|
||||
**ADR-0049**), which would have collided.
|
||||
|
||||
A dedicated infrastructure session that built the project's **entire CI/CD
|
||||
pipeline** on the self-hosted Gitea Actions runner — from nothing to a live
|
||||
gate plus a six-target cross-platform release. Net: the **CI** /
|
||||
`requirements.md` **TT5** item and **D1**/**D2** are now done; **D3** and a
|
||||
couple of TT5 tails remain. Decisions are recorded in the sibling ADR namespace
|
||||
**`docs/ci/adr/`** (ADR-ci-001/002/003).
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `ci` (worktree). **`main` has been merged into `ci`** (commit
|
||||
`138e766`, clean — `ci` and `main` touched disjoint files) so the gate runs
|
||||
against current `main` before CI lands there. Working tree clean except the
|
||||
in-progress doc updates from this handoff. Pushes/promotion are the user's
|
||||
step.
|
||||
|
||||
**Gate verified locally on the merged code:** `clippy -D warnings` clean;
|
||||
**`cargo test` 2488 passing / 0 failing / 1 ignored** (the long-standing
|
||||
`friendly` doctest). main's features came in with their tests (2424 → 2488).
|
||||
|
||||
**Pipeline (`.gitea/workflows/`):**
|
||||
|
||||
- `build-ci-image.yaml` — builds + pushes the CI image (`node:22-bookworm-slim`
|
||||
+ single-user nix + the flake's devShell pre-warmed) to the Gitea registry.
|
||||
Triggers only on image-input changes (Dockerfile / flake / toolchain).
|
||||
- `ci.yaml` — the gate: `clippy -D warnings` + `cargo test`, branch pushes + PRs
|
||||
(docs-only changes skipped).
|
||||
- `release.yaml` — on a `v*` tag: `test` → `build` matrix over the **four
|
||||
non-macOS** targets via `cargo-zigbuild`, upload to the Gitea release.
|
||||
- `release-macos.yaml` — **workflow_dispatch** (tag input) on the Tart
|
||||
Apple-Silicon runner (`runs-on: macos`): test → build both `*-apple-darwin`
|
||||
→ de-nix `libiconv` + ad-hoc re-sign → upload.
|
||||
|
||||
**Verified live this session:** the 4-target release published **8 assets**
|
||||
(binary + `.sha256` each) for tag `v.0.0.0-citest3`; the macOS build was proven
|
||||
portable (system-only deps) + signed + launches on the runner.
|
||||
|
||||
## §2. What was built (and the non-obvious bits)
|
||||
|
||||
- **Nix flake** (ADR-ci-002, relocated from a would-be `main` ADR-0049): one
|
||||
pinned toolchain (`1.95.0`) for dev *and* CI; `cargo-zigbuild` + `zig` (Linux
|
||||
only) for the cross targets; `apple-sdk` on darwin.
|
||||
- **Runner facts** (ADR-ci-001): jobs run *inside* a container (`ci-public` →
|
||||
`catthehacker/ubuntu`), so host nix is unreachable — hence the baked image.
|
||||
The Mac runner is **host execution**; its label is `macos` (`:host` in the
|
||||
registration is the act_runner backend, not part of the label).
|
||||
- **Cross-compile** (ADR-ci-003): `cargo-zigbuild` for the 4 non-macOS targets.
|
||||
Windows needs an **empty `libsynchronization.a` stub** (`ci/winstub/`, wired
|
||||
via `.cargo/config.toml`) — std links `-lsynchronization`, absent from
|
||||
rust-overlay's toolchain + zig's mingw, but forwarded by `kernel32`.
|
||||
- **macOS** (ADR-ci-003 amendment): built on **real Apple hardware** (Tart), so
|
||||
the SDK is fully licensed — no osxcross grey area. The darwin stdenv bakes a
|
||||
`/nix/store` `libiconv` path into the binary; the build rewrites it to
|
||||
`/usr/lib/libiconv.2.dylib` (`install_name_tool`) and re-signs ad-hoc
|
||||
(`codesign -f -s -`; `install_name_tool` invalidates the signature, arm64
|
||||
refuses unsigned). A guard fails the build on any remaining `/nix/store` dep.
|
||||
- **Cache hygiene (Mac):** the runner wipes the workspace each run, so cargo
|
||||
`target/` never accumulates; the persistent nix store is bounded by
|
||||
**generation** (record the devShell in a persistent profile, keep the 2
|
||||
newest via `nix-env --delete-generations +2`, GC the rest). First sweep
|
||||
reclaimed a ~3.8 GB one-time backlog of build scaffolding (source + build-only
|
||||
deps, *not* re-installed toolchains).
|
||||
|
||||
## §3. Immediate next steps (user)
|
||||
|
||||
1. **Push `ci`** → the gate re-runs in CI (should be green; no image rebuild —
|
||||
the merge didn't touch the flake/Dockerfile).
|
||||
2. **Promote:** `git checkout main && git merge ci` — a **fast-forward** (`ci`
|
||||
already contains `main`) — then push `main`. CI goes live; `release-macos`
|
||||
becomes dispatchable (workflow_dispatch needs the default branch).
|
||||
3. **First real release:** tag `v0.1.0` (auto-builds the 4 Linux/Windows
|
||||
assets), then **dispatch `release-macos` for `v0.1.0`** with the Mac up (adds
|
||||
the 2 macOS assets) → a full 6-binary release.
|
||||
4. **Cleanup:** delete the `v.0.0.0-citest*` test tags + their releases.
|
||||
5. **Runner-side:** add `min-free`/`max-free` to the Mac's `/etc/nix/nix.conf`
|
||||
as a hands-off nix-store backstop.
|
||||
|
||||
## §4. Known gaps / follow-ups
|
||||
|
||||
- **Versioning is not wired into the binary** (flagged by the user). The release
|
||||
**git tag is nowhere in the produced binary** — there is no `--version` flag,
|
||||
no `CARGO_PKG_VERSION` use anywhere in `src/`, and the release workflows use
|
||||
the tag only for the *release name* + *asset filenames*
|
||||
(`rdbms-playground-<tag>-<target>`). `Cargo.toml` is a static `version =
|
||||
"0.1.0"`, decoupled from the tag. So a `v0.5.0` tag yields a `…-v0.5.0-…`
|
||||
asset whose binary knows nothing of "0.5.0". To fix later: add a `--version`
|
||||
flag, and inject the tag at build time (e.g. a `build.rs` reading a
|
||||
CI-provided env, or bumping `Cargo.toml` as part of tagging) so the binary and
|
||||
the release agree.
|
||||
- **D3 packaging** — Homebrew / Scoop / winget / `cargo binstall` manifests
|
||||
(asset naming is already binstall-friendly).
|
||||
- **TT5 tails** — Windows is build-only (no execution runner); Tier-4 PTY (TT4)
|
||||
is unwired in CI.
|
||||
- **`fmt` gate** — deliberately off (tree isn't stock-`rustfmt`-clean); revisit
|
||||
on `main`.
|
||||
- **Website → Cloudflare** deploy — the separate, simpler workflow, still to do.
|
||||
@@ -0,0 +1,21 @@
|
||||
# CI / Build subproject — session handoffs
|
||||
|
||||
Handover notes for the **CI / release pipeline** work (the Gitea Actions
|
||||
workflows under `.gitea/`, the nix flake, the release tooling). Kept in their
|
||||
own namespace, separate from the project-wide session handoffs in
|
||||
[`docs/handoff/`](../../handoff/), so a CI-branch handoff never competes with
|
||||
`main`'s global handoff sequence for numbers — the same split the CI ADRs use
|
||||
([`docs/ci/adr/`](../adr/README.md)). This is not hypothetical: `main`
|
||||
independently wrote a `handoff-70` the same day this subproject's first handoff
|
||||
was drafted.
|
||||
|
||||
**Numbering.** Files are named `<date>-handoff-ci-<NN>.md` and referenced in
|
||||
prose as `handoff-ci-NN`. Assign the next free `NN` from this index.
|
||||
|
||||
## Index
|
||||
|
||||
- [handoff-ci-01 — the CI/release pipeline build-out](20260615-handoff-ci-01.md)
|
||||
— Gitea Actions gate (clippy + test) + a six-target release (four via
|
||||
`cargo-zigbuild` on a `v*` tag, two macOS via dispatch on a Tart runner), all
|
||||
on a nix flake; decisions in `docs/ci/adr/`. Built on the `ci` branch, merged
|
||||
`main` in, gate green (2488 tests), ready to promote to `main`.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Website-branch handoff — 2026-06-10 (website-1)
|
||||
|
||||
First handoff for the **website** work. This is a **separate sequence** from
|
||||
`main`'s `YYYYMMDD-handoff-NN.md` files — branch-scoped name on purpose, so
|
||||
the two don't collide. Continue numbering these `…-website-handoff-N.md`.
|
||||
|
||||
## State
|
||||
|
||||
- **Branch:** `website`. **HEAD `936d925`.** Not pushed (push is the user's
|
||||
step). Working tree clean.
|
||||
- The website lives in **`website/`** (monorepo; the playground crate is at
|
||||
the repo root). Decisions: **ADR-0044**
|
||||
(`docs/adr/0044-public-website-and-documentation-site.md`). Implementation
|
||||
plan: **`docs/plans/20260604-adr-0044-website.md`**. Living style guide:
|
||||
**`website/STYLE.md`** (read this first — it has the binding conventions
|
||||
and an open-decisions log).
|
||||
|
||||
## Stack & layout
|
||||
|
||||
- **Astro 6 + Starlight + Tailwind v4**, all under `website/`.
|
||||
- `website/astro.config.mjs` — Starlight config (title, 5-section sidebar,
|
||||
Expressive Code `langs`, `server.host: '127.0.0.1'`).
|
||||
- `website/src/grammars/rdbms.mjs` — custom Shiki grammar, **two language
|
||||
ids**: `rdbms` (real commands) and `rdbms-syntax` (abstract templates).
|
||||
- `website/src/styles/global.css` — Starlight↔Tailwind bridge + the `> `
|
||||
command prompt + copy-button hiding (CSS `:has()`).
|
||||
- `website/src/content/docs/` — the five sections.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
cd website
|
||||
pnpm install # node_modules is gitignored — reinstall on a fresh checkout
|
||||
pnpm dev # serves http://127.0.0.1:4321 (see dev-server gotcha below)
|
||||
pnpm build # static dist/, 24 pages, Pagefind search index
|
||||
```
|
||||
|
||||
**Dev-server gotcha (already fixed, don't re-break):** Astro/Vite's default
|
||||
`localhost` bind resolves to IPv6 `::1` here, which breaks SSH
|
||||
`-L 4321:127.0.0.1:4321` tunnels. `server.host: '127.0.0.1'` in the config
|
||||
pins IPv4. Tunnel with `ssh -L 4321:127.0.0.1:4321 <host>`.
|
||||
|
||||
**Verify after changes:** `pnpm build` clean; then from the repo root
|
||||
`grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/`
|
||||
must be empty; internal links should resolve (build doesn't fail on broken
|
||||
links — no validator installed — so sanity-check by hand or with a small
|
||||
script).
|
||||
|
||||
## Documentation structure (5 sections, autogenerated per directory)
|
||||
|
||||
1. **Getting started** — install, first-project, modes, example-library. **(real)**
|
||||
2. **Using the playground** — command-line-options, the-assistive-editor,
|
||||
the-output-pane, projects, undo-and-history, export-and-import,
|
||||
copy-to-clipboard, getting-help. **(real, grounded)** *This is "the app you
|
||||
drive", distinct from the database-language Reference.*
|
||||
3. **Guides** — build-the-library **(DRAFT — marked; to be iterated for
|
||||
teaching quality before publication)**.
|
||||
4. **Reference** — types **(real)**, tables **(real)**; columns,
|
||||
relationships, indexes, constraints, inserting-and-editing-data,
|
||||
querying-and-inspecting **(STUBS — real syntax synopsis + an "In progress"
|
||||
note; THEY NEED WORKED EXAMPLES — this is the main remaining bulk)**.
|
||||
5. **Concepts** — projects-and-storage **(real)**.
|
||||
- Landing: `index.mdx` splash (feature cards incl. the assistive editor +
|
||||
start-here links).
|
||||
|
||||
## Binding conventions (STYLE.md / ADR-0044 §7)
|
||||
|
||||
- **No "DSL"** in user-facing copy → "simple mode" / "advanced mode".
|
||||
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) → "the database" / "the
|
||||
engine".
|
||||
- **Code fences:** simple-mode commands → ` ```rdbms ` (highlighted; gets a
|
||||
decorative copy-safe `> ` prompt via CSS). Abstract syntax templates →
|
||||
` ```rdbms-syntax ` (highlighted, **no** prompt, no copy button). Advanced
|
||||
SQL → ` ```sql `. Shell → ` ```sh `.
|
||||
- **One command per line** in `rdbms` blocks. A multi-line *single* statement
|
||||
(advanced `CREATE TABLE`) goes in ` ```sql `.
|
||||
- **Copy button** is hidden on multi-line `rdbms` blocks and on
|
||||
`rdbms-syntax` (the app input is single-line — a multi-command paste isn't
|
||||
runnable); kept on single-command `rdbms`, and all `sql`/`sh`.
|
||||
- Unshipped features → `:::caution[Planned]` aside; never presented as
|
||||
shipped.
|
||||
- **Ground every page in source** — `parse.usage.*` / `help.*` in
|
||||
`src/friendly/strings/en-US.yaml`, `src/dsl/command.rs`, `src/dsl/types.rs`,
|
||||
the ADRs. **Do not** trust `requirements.md` markers (handoff-59 found
|
||||
~46% mis-marked; it now uses a `[/]` partial legend) — verify against code.
|
||||
|
||||
## Canonical example database (use in every example)
|
||||
|
||||
A small **library**: `authors`(author_id serial pk, name text, birth_year
|
||||
int) · `books`(book_id serial pk, title text, author_id int→authors,
|
||||
published int, isbn text unique) · `members`(member_id serial pk, name text,
|
||||
joined date) · `loans`(loan_id serial pk, book_id int→books, member_id
|
||||
int→members, loaned_on date, returned_on date). 1:n author→books; m:n
|
||||
books↔members via loans. **Simplest examples lead with bare `with pk`** (a
|
||||
default `id` key); the library build uses **named** keys (`author_id`, …) so
|
||||
relationships read clearly.
|
||||
|
||||
## Verified syntax cheat-sheet (don't re-derive)
|
||||
|
||||
- Simple create: `create table <T> with pk [<col>(<type>)[, ...]]` (bare =
|
||||
default `id`); `add column [to] [table] <T>: <name> (<type>)`;
|
||||
`rename column [in] [table] <T>: <old> to <new>`; `change column … (<type>)
|
||||
[--force-conversion|--dont-convert]`; `drop column [from] [table] <T>: <col>
|
||||
[--cascade]`.
|
||||
- Advanced create: `create table T (id serial primary key, …, col int
|
||||
references parent(col))` (verified against `tests/it/sql_create_table.rs`).
|
||||
- Relationship: `add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
|
||||
[on delete <action>] [on update <action>] [--create-fk]`;
|
||||
compound: `from <P>.(a, b) to <C>.(x, y)`; drop by name or by endpoints.
|
||||
- Data: `insert into T [(cols)] [values] (vals)`; `update T set … (where … |
|
||||
--all-rows)`; `delete from T (where … | --all-rows)`.
|
||||
- Inspect: `show data <T> [where …] [limit n]`, `show table <T>`, `show
|
||||
tables|relationships|indexes`, `show relationship|index <name>`.
|
||||
- `explain <…>` (query plan; safe — never executes). Advanced `select …`.
|
||||
- 10 types: text int real decimal bool date datetime blob serial shortid;
|
||||
advanced-mode SQL aliases (integer/varchar/timestamp/numeric/…).
|
||||
- App commands: save / save as / new / load / rebuild / export [path] /
|
||||
import <zip> [as <t>] / undo / redo / replay <path> / mode / help
|
||||
[<command>] / help types / copy [all|last] / quit. (`hint` and `seed` are
|
||||
NOT implemented — mark planned/omit.)
|
||||
|
||||
## Next work (priority order)
|
||||
|
||||
1. **Fill the 6 Reference stubs** with worked examples on the library schema
|
||||
(the remaining bulk). Each has a syntax synopsis + an "In progress" note —
|
||||
expand to full content: worked example(s), both simple + advanced forms
|
||||
where both apply, cross-links. This is the biggest chunk.
|
||||
2. **Iterate the Guides** for teaching quality; add guides (model a 1:n / m:n,
|
||||
querying with joins). The user flagged guides as the most important
|
||||
didactic content, to be polished before publication.
|
||||
3. **Phase B — landing polish:** use the `frontend-design` skill; set
|
||||
Starlight `site` (production URL) once the domain is known (enables sitemap
|
||||
+ OG/SEO); add a logo + favicon (small Starlight config). Branding palette
|
||||
when the user wants it (staying on Starlight; community themes were
|
||||
surveyed — see ADR-0044 / chat).
|
||||
4. **asciinema casts — DEFERRED until the app is final** (ADR-0044 §2). When
|
||||
starting, settle STYLE.md open-decision #9: scripted-input driver
|
||||
(`asciinema-automation` vs `autocast` — prove with a throwaway test run),
|
||||
`.cast` script format + repo location, terminal geometry, light/dark
|
||||
player theme, file naming. The **assistive editor** is prime cast material
|
||||
(completion / `[ERR]`/`[WRN]` indicator are motion a still block can't
|
||||
show) — earmark a cast for it on the landing + the assistive-editor page.
|
||||
5. Remaining STYLE.md open decisions: **versioning** (leaning single-version
|
||||
for launch) and **SEO/meta** (settle with Phase B + the `site` URL).
|
||||
|
||||
## Process pins
|
||||
|
||||
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
|
||||
**append-only** (no amend/rebase/force-push). Push is the user's step.
|
||||
- **ADR numbering:** assigned at merge-to-`main` (ADR-0000 "Numbering
|
||||
discipline", added this branch). The website ADR is **0044** — renumbered
|
||||
from 0042 on the `main` merge, because `main` had independently used 0042
|
||||
(H1a) and 0043 (compound-FK).
|
||||
- **Issues:** Gitea via `tea` (repo `oli/rdbms-playground` on
|
||||
`git.lazyeval.net`); append `< /dev/null` + `timeout 30`; never raw API.
|
||||
- **Escalate genuine forks**, declare epistemic status, write down the DA pass
|
||||
(`/runda`) on non-trivial plans.
|
||||
|
||||
## Commit history on this branch (newest first)
|
||||
|
||||
```
|
||||
936d925 feat: add "Using the playground" section + Reference skeleton
|
||||
44390e7 feat: simple-mode code-block highlighting, prompt, and copy rules
|
||||
995c0ba docs: reconcile website doc inventory with merged main scope
|
||||
c72c624 chore: bind website dev/preview server to IPv4 loopback (127.0.0.1)
|
||||
9e774b2 docs: ADR numbering discipline — assign numbers at merge-to-main
|
||||
40de389 Merge branch 'main' into website (Gitea migration + ADR renumber)
|
||||
0fcb7b1 docs: website docs structure + first content pages
|
||||
cea99e8 chore: scaffold website (Astro 6 + Starlight + Tailwind v4)
|
||||
1fad29c docs: ADR-0042 — public website + documentation site plan (now ADR-0044)
|
||||
```
|
||||
|
||||
## Review status (what the user has signed off)
|
||||
|
||||
Highlighting / `> ` prompt / copy behavior — good. Voice, altitude,
|
||||
terminology — good. Responsive layout (checked in Polypane) — good. Locked
|
||||
decisions: bare `with pk` leads the simplest examples; copy hidden on
|
||||
multi-command blocks (not per-command copy); the 5-section structure with
|
||||
"Using the playground" near the top; assistive editor surfaced on
|
||||
landing + Getting started. **The 6 Reference stubs have not been reviewed for
|
||||
content** — only their syntax synopses exist.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Website-branch handoff — 2026-06-11 (website-2)
|
||||
|
||||
Second handoff for the **website** work (separate sequence from `main`'s
|
||||
`YYYYMMDD-handoff-NN.md`). Read `website-1` (2026-06-10) first for the
|
||||
original scaffolding context; this note covers everything since.
|
||||
|
||||
## State
|
||||
|
||||
- **Branch:** `website`. **HEAD `e782a28`.** Working tree clean. Pushed
|
||||
through an earlier point; currently **ahead of `origin/website`** (push is
|
||||
the user's step — never push).
|
||||
- The website lives in **`website/`** (monorepo; the playground crate is at
|
||||
the repo root). Living style guide + binding conventions:
|
||||
**`website/STYLE.md`** (read first). Website decisions:
|
||||
**`docs/website/adr/20260604-adr-website-001.md`** (the website ADR namespace
|
||||
— see below). Plan: `docs/website/plans/20260604-website-implementation-plan.md`.
|
||||
|
||||
## What changed since website-1 (commit highlights, newest first)
|
||||
|
||||
```
|
||||
e782a28 feat(website): projects cast (vi-nav load picker) + --demo on all casts
|
||||
927e6b2 Merge branch 'main' (m:n, logging, UI nav, demo overlays, vi-nav)
|
||||
52860c3 feat(website): casts for first-project/modes/undo-redo; quit via Ctrl-C
|
||||
ce153bd docs(website): add SQL queries reference page (advanced query surface)
|
||||
302329d docs(website): record the cast-placement policy in STYLE.md
|
||||
bb7887e feat(website): relationship-diagram cast on the relationships page
|
||||
65a48fa feat(website): joins cast on the querying-with-joins guide
|
||||
c0cc92a docs(website): rewrite Build the library + add Querying with joins guide
|
||||
10655e4 docs(website): fill the six Reference stubs with worked examples + output
|
||||
a8f84c9 feat(website): refine casts — trim shell, autoplay+loop landing, cap size
|
||||
… (cast pipeline, the astro/starlight upgrade, the ADR-namespace move)
|
||||
```
|
||||
|
||||
## ADR namespace (important — avoids the recurring collision)
|
||||
|
||||
Website decision records live in their **own namespace**:
|
||||
`docs/website/adr/` (`<date>-adr-website-<NNN>.md`, id `ADR-website-NNN`),
|
||||
indexed by `docs/website/adr/README.md`. They do **not** draw from `main`'s
|
||||
global ADR integer pool, so a `main` ADR and a website ADR can never collide
|
||||
again (this is why the latest merge of `main`'s ADR-0045/0046/0047 was
|
||||
conflict-free). Recorded in ADR-0000 "Numbering discipline". The main
|
||||
`docs/adr/README.md` intro carries a pointer to the website namespace.
|
||||
|
||||
## Content status
|
||||
|
||||
**Done & verified (build clean, grounded in source, forbidden-terms clean):**
|
||||
- **Getting started** — installation, first-project, modes, example-library.
|
||||
- **Using the playground** — command-line-options, the-assistive-editor,
|
||||
the-output-pane, projects, undo-and-history, export-and-import,
|
||||
copy-to-clipboard, getting-help.
|
||||
- **Guides** — build-the-library (full 4-table build, 1:n + m:n bridge),
|
||||
querying-with-joins.
|
||||
- **Reference** — types, tables, columns, relationships, indexes, constraints,
|
||||
inserting-and-editing-data, querying-and-inspecting, **sql-queries** (the
|
||||
advanced SELECT surface: DISTINCT, GROUP BY/HAVING, set ops, subqueries,
|
||||
CTEs, CASE/CAST/functions, with a "supported subset" boundary note).
|
||||
- **Concepts** — projects-and-storage.
|
||||
- Landing `index.mdx` splash with the quickstart cast.
|
||||
|
||||
**26 pages build clean.** Only expected warning: sitemap needs `site` (Phase B).
|
||||
|
||||
## asciinema casts — the pipeline + the 7 casts
|
||||
|
||||
Pipeline (STYLE.md "asciinema casts", ADR-website-001 §2):
|
||||
- **Driver: `autocast`** (chosen by spike; `asciinema-automation` can't drive a
|
||||
full-screen TUI). Sources in `website/casts-src/casts.mjs`; `pnpm casts`
|
||||
runs `casts-src/generate.mjs` → autocast YAML → `public/casts/<name>.cast`.
|
||||
Command-lists are the durable source; **`.cast` files are regenerable** —
|
||||
re-run `pnpm casts` (needs a `cargo build` binary at `../target/debug`).
|
||||
- **Components:** `Cast.astro` (asciinema-player island) wrapped by
|
||||
`Demo.astro` (the WASM-swap seam, ADR-website-001 §3). Embed via `<Demo
|
||||
src="/casts/NAME.cast" … />` in an **.mdx** page (md can't import).
|
||||
- **Generator features:** trims each cast to the in-app region (drops shell
|
||||
launch + the return-to-shell); ends casts with **Ctrl-C** (`{ key: 'CtrlC' }`)
|
||||
not a typed `quit` (invisible, so the cast ends on the last content frame);
|
||||
per-cast `holdEnd`, `dataDir` (isolated data root, wiped per run), and
|
||||
**`--demo` is default-on** (opt out with `demo:false`).
|
||||
- **The 7 casts:** quickstart (landing, autoplay+loop), assistive-editor,
|
||||
relationship-diagram, joins, modes, undo-redo, **projects** (the new one:
|
||||
save as → new → load via the picker, navigated with `j`).
|
||||
|
||||
### `--demo` (#22 / ADR-0047) is on for ALL casts
|
||||
The demonstration overlay shows a **badge** for special keystrokes
|
||||
(`[ENTER]`, `[TAB]`, arrows, etc.) — plain characters are NOT badged. This
|
||||
makes e.g. the assistive-editor's Tab completion visible (`[TAB]`), and the
|
||||
projects cast's `j`/`k` picker navigation stays *un*surfaced (plain chars) by
|
||||
design. Captions exist too (a stealth `Ctrl+]`-delimited banner) — usable in
|
||||
casts for neutral "point something out" labels, not yet used.
|
||||
|
||||
### ⚠️ Cast tooling limits (don't rediscover these)
|
||||
- autocast can only send **single characters, ASCII control codes (`^X`), and
|
||||
waits** — **NO arrows / PageUp/PageDown / Home/End / function keys** (those
|
||||
are escape sequences; the per-key delay makes the terminal read a lone Esc —
|
||||
verified). So any interaction we want to demo must be reachable via typeable
|
||||
keys. This is why #24 (vi `j/k` in the picker) was needed for the projects
|
||||
cast, and why **output-pane scrolling has no cast** (needs PgUp/PgDn).
|
||||
- `Ctrl+]` (caption toggle) and `Ctrl-C` (quit) ARE sendable (`^]`, `^C`).
|
||||
|
||||
### ⚠️ No-advertising constraint (user, 2026-06-11)
|
||||
The docs must **NOT** advertise that the load picker supports **vi keys**, nor
|
||||
that **`Ctrl+]`** is the caption/banner trigger. The `--demo` flag itself MAY
|
||||
be documented lightly as "a teaching helper that shows special keystrokes" —
|
||||
and nothing more. Casts may *use* vi-nav and captions (the viewer sees only the
|
||||
result/banner, not the keystroke), but cast captions must not name `j/k` or
|
||||
`Ctrl+]`.
|
||||
|
||||
## NEXT WORK — priority order
|
||||
|
||||
### 1. Document the features the `main` merge brought (the biggest gap)
|
||||
The merge (`927e6b2`) added app features that are **not yet documented**:
|
||||
|
||||
- **m:n convenience command** — `create m:n relationship …` (C4, **ADR-0045**).
|
||||
The relationships page currently models m:n only via the manual loans-bridge.
|
||||
Document the convenience command (it auto-generates the junction table).
|
||||
Ground in ADR-0045 + `tests/it/m2n.rs` + `tests/typing_surface/create_m2n.rs`
|
||||
for exact syntax. Likely a new section on the **relationships** reference
|
||||
page and/or a mention in the build-the-library guide.
|
||||
- **`--demo` flag** — document on **command-line-options** as a teaching helper
|
||||
that "shows special keystrokes" (per the no-advertising constraint above —
|
||||
do NOT mention badges-for-vi or captions/`Ctrl+]`).
|
||||
- **ADR-0046 UI** — the **schema sidebar** (auto-shows on wide terminals,
|
||||
`Ctrl-O` navigation mode to peek/expand), **responsive two-row input** +
|
||||
horizontal scroll, and the geometry-fixed hint panel. Decide where in *Using
|
||||
the playground* (a new "the schema sidebar" page, or fold into the-output-pane
|
||||
/ the-assistive-editor). Ground in ADR-0046.
|
||||
- FK-message fixes + the X1 logging sweep: **no user-doc impact** (note only).
|
||||
|
||||
Whenever output changed because of the merge, **re-verify any affected static
|
||||
output blocks** (capture-harness recipe below).
|
||||
|
||||
### 2. Consider a final cast re-record sweep + optional captions
|
||||
- All casts re-record with `pnpm casts` once the app is "final".
|
||||
- **Chase up two pacing/clarity guidelines across the existing casts** (added
|
||||
to STYLE.md "Cast pacing & clarity" 2026-06-11; the projects cast already
|
||||
follows them):
|
||||
1. **Don't submit a command too fast** — a typed-and-`Enter`ed-in-one-instant
|
||||
command vanishes before the viewer reads it. Re-review each cast for
|
||||
type-then-instant-Enter (especially modal confirms / short commands) and
|
||||
add a pause before `Enter` (split `type` and `key:'Enter'` steps).
|
||||
2. **Show state where the sidebar would** — at 90 cols the schema sidebar is
|
||||
hidden (ADR-0046), so insert `show tables` / `show table` / `show data`
|
||||
where state changes, so the viewer can follow what happened.
|
||||
- **Review whether caption banners would improve the existing casts.** The
|
||||
demo overlay can show a neutral step **caption** (the stealth `Ctrl+]`
|
||||
banner) to label or narrate a moment — e.g. marking the phases of the
|
||||
build-the-library/projects casts, or calling out the relationship diagram /
|
||||
the teaching echo in the modes cast. Go cast-by-cast and decide where a
|
||||
caption adds clarity vs. adds noise. Constraint: caption **text must not name
|
||||
keys** (no `j/k`, no `Ctrl+]`); it narrates *what* is happening, not *how* it
|
||||
was typed. (Captions are wired but not yet used in any cast.)
|
||||
- Output-pane scrolling cast remains blocked (PgUp/PgDn unsendable). If desired
|
||||
later, it needs an app-side typeable scroll key (file an enhancement like #24)
|
||||
— otherwise leave it to static docs.
|
||||
|
||||
### 3. Phase B — landing/site polish
|
||||
- Set Starlight **`site`** (production URL) → clears the sitemap warning,
|
||||
enables sitemap + canonical/OG. Then SEO/meta conventions (STYLE #8).
|
||||
- Logo + favicon; branding palette (staying on Starlight).
|
||||
- **Light/dark player theme**: the asciinema player theme is currently fixed
|
||||
(`asciinema`); sync it to the Starlight theme toggle (folded into STYLE #8).
|
||||
- Use the `frontend-design` skill.
|
||||
|
||||
### 4. Open STYLE.md decisions
|
||||
- **#7 Versioning** (leaning single-version for launch).
|
||||
- **#8 SEO/meta** + the player light/dark theme.
|
||||
|
||||
## Capture-harness recipe (how to get accurate static output for new pages)
|
||||
|
||||
Output blocks must be **captured from the real app, never hand-drawn**
|
||||
(STYLE.md). Pattern used throughout:
|
||||
- For `pub` render fns (`render_data_table`, `render_explain_plan`) + the DB
|
||||
worker API: a throwaway **external** test `tests/doc_capture.rs` that builds
|
||||
the library schema via `Database` and prints rendered output; run
|
||||
`cargo test --test doc_capture -- --nocapture --ignored`; paste verbatim
|
||||
(trim trailing spaces); delete the file.
|
||||
- For `pub(crate)` fns (`render_structure_with_diagrams`,
|
||||
`render_relationship_diagram`): an in-crate `#[ignore]` test in
|
||||
`src/output_render.rs`'s test module instead.
|
||||
- **Verify box-drawing integrity** after pasting (top `┬` count == bottom `┴`,
|
||||
equal line lengths) — a mis-paste truncated a CTE border once and the check
|
||||
caught it.
|
||||
|
||||
## Verify-after-changes checklist
|
||||
```sh
|
||||
cd website && pnpm build # clean; 26 pages; only the `site` warning
|
||||
# from repo root — forbidden terms must be empty:
|
||||
grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/
|
||||
# internal links + heading anchors: spot-check in dist/ (no link validator installed)
|
||||
cd website && pnpm casts # regenerate all casts (needs target/debug binary)
|
||||
```
|
||||
Dev server + tunnel for visual checks (player playback, sizing, badges):
|
||||
`cd website && pnpm dev` (binds 127.0.0.1:4321) then `ssh -L 4321:127.0.0.1:4321 <host>`.
|
||||
**Visual playback of all 7 casts (now with `--demo` badges) is still pending a
|
||||
tunnel check by the user.**
|
||||
|
||||
## Process pins
|
||||
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
|
||||
**append-only** (no amend/rebase/force-push). Push is the user's step.
|
||||
- **Ground every page in source** (`src/dsl/*`, `en-US.yaml`, the ADRs) — not
|
||||
`requirements.md` markers. No engine name, no "DSL" in user-facing copy.
|
||||
- **Issues** via `tea` (repo `oli/rdbms-playground` on `git.lazyeval.net`;
|
||||
append `< /dev/null` + `timeout 30`). Open/related: **#22** (demo overlay,
|
||||
implemented), **#24** (vi picker nav, implemented). Both merged via `927e6b2`.
|
||||
- Escalate genuine forks; declare epistemic status; write down the `/runda` DA
|
||||
pass on non-trivial plans.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Session handoff — 2026-06-12 (68)
|
||||
|
||||
Sixty-eighth handover. Continues directly from handoff-67 (which
|
||||
triaged a manual-testing pass into fixes + filed issues). This was an
|
||||
**issue-burndown session**: six Gitea issues closed across five
|
||||
commits, each landed with the full phased workflow + a `/runda` +
|
||||
Devil's-Advocate pass before commit. Net: **six issues closed, five
|
||||
commits, +29 tests, zero regressions.**
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. Working tree **clean**; all work committed.
|
||||
**Five unpushed commits** (push is the user's step).
|
||||
|
||||
**Tests: 2436 passing / 0 failing / 0 skipped / 1 ignored** (the
|
||||
long-standing `friendly` doctest). **Clippy clean** (nursery, all
|
||||
targets). Breakdown: 1730 lib + 506 integration (`it`) + 200
|
||||
typing-surface-matrix. +29 over handoff-67's 2407.
|
||||
|
||||
**Commits since handoff-67:**
|
||||
```
|
||||
ee3ccd8 feat(hint): advertise the optional seed count in the hint panel (#26)
|
||||
deb0948 feat(seed): year-as-int + conventional choice-set heuristics (#33, #34)
|
||||
fde50ce fix(ui): mark sidebar focus with an accent colour, not bold (#25)
|
||||
3d4a0fd fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32)
|
||||
7e4bc12 fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31)
|
||||
```
|
||||
|
||||
## §2. Issues closed this session (all committed, all tested, all `/runda`-reviewed)
|
||||
|
||||
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
|
||||
comment. The `/runda` pass earned its keep on every one — see the
|
||||
"DA caught" notes.
|
||||
|
||||
1. **#31 (`7e4bc12`) — bare table alias treated as unknown column.**
|
||||
A bare in-scope table alias in a SQL expression (`… GROUP BY o`,
|
||||
`o` aliasing `FROM Orders o`) got `no such column o on table …` and
|
||||
zero completions. Now: completion offers each FROM source's
|
||||
*qualifier* (alias-if-present-else-table) at a bare `sql_expr_ident`
|
||||
slot; the `matched.len()==0` arm emits a targeted
|
||||
`alias_used_as_column` / `table_used_as_column` hint after the
|
||||
projection-alias check. **DA caught** two real bugs pre-commit: a
|
||||
DSL leak (the hint fired for simple-mode `expr_column` refs, which
|
||||
have no `table.column` syntax) and wrong advice for an
|
||||
aliased-table-by-real-name — both fixed by gating on
|
||||
`role == "sql_expr_ident"` + matching the *effective qualifier*.
|
||||
ADR-0032 Amendment 3.
|
||||
|
||||
2. **#32 (`3d4a0fd`) — decimal aggregation float noise.** `decimal`
|
||||
is exact TEXT, but SQLite has no decimal type, so arithmetic
|
||||
coerces to IEEE-754 double; `sum(price*qty)` rendered
|
||||
`298.59999999999997`. Now `format_real_display` (db.rs) rounds REAL
|
||||
to 15 sig figs **for display only**, wired into `format_cell`.
|
||||
**DA caught** a real regression: I'd also wired it into
|
||||
`render_value`, which is a *canonical identity key* for the
|
||||
uniqueness dry-runs (`dry_run_unique`, `check_uniqueness_collisions`)
|
||||
— rounding there would report collisions the exact-valued engine
|
||||
wouldn't. Reverted `render_value` to exact; locked with a
|
||||
regression test. CSV/FK-key/EXPLAIN paths stay exact. ADR-0005
|
||||
Amendment 1.
|
||||
|
||||
3. **#25 (`fde50ce`) — sidebar focus accent colour, not bold.** Bold
|
||||
box-drawing glyphs render broken in asciinema casts.
|
||||
`panel_border_style` now uses a non-bold accent colour
|
||||
(`theme.mode_simple`); bold stays fine on text spans. **DA caught**
|
||||
that the issue's "Tier-2 snapshots need re-accepting" was wrong —
|
||||
`render_to_string` is text-only, so no snapshot changed. Added a
|
||||
render-level test that inspects the actual border *cells*.
|
||||
User visually confirmed. ADR-0046 Amendment 1.
|
||||
|
||||
4. **#33 + #34 (`deb0948`) — seed heuristics: year-as-int + choice
|
||||
sets.** Two additive D7 catalogue rules. **#33:** `year`/`*_year`/
|
||||
`published`/`founded` → bounded `int` year (1950–2025, or the
|
||||
`dob`-style birth window 1945–2007 for `birth`/`born`/`dob`); new
|
||||
`YearRecent`/`YearBirth` generators. Placed *after* the quantity
|
||||
rule so `year_count` stays a count. **#34:** type-gated `PickFrom`
|
||||
sets for `priority`/`prio`, `severity`, `rating`/`stars`; `status`
|
||||
**deliberately excluded** (user-confirmed on the issue — values too
|
||||
domain-specific). `priority` left `ENUM_TOKENS`. A user `IN`-CHECK
|
||||
still wins. **DA/process caught** that I'd skipped reading the issue
|
||||
*comments* (where the `status` decision + a website cast note lived)
|
||||
— **lesson: always read issue comments**. Also closed a
|
||||
pre-existing column-fill integration-test gap. ADR-0048 Amendment 1.
|
||||
|
||||
5. **#26 (`ee3ccd8`) — optional `count` advertised in the hint panel.**
|
||||
At `seed <table> ▮` only `set`/`--seed` chips showed; the optional
|
||||
row count (a bare positional number) was invisible, and the prior
|
||||
`IntroProse` attempt was reverted because `pending_hint_mode` is
|
||||
cleared by the trailing optionals. Now `walk_optional` stashes a
|
||||
skipped inner's `IntroProse` key into a new
|
||||
`WalkContext.surviving_intro_hint` (key + position) before the empty
|
||||
match clears it; a **position guard** (`pos == cursor`) stops it
|
||||
leaking past a later `set …` clause or once the count is given. Tab
|
||||
still cycles the keywords. Prose mentions the count, `.column`
|
||||
column-fill, `set`, and `--seed` (user-chosen scope). **DA caught**
|
||||
a coverage gap (advanced-mode path untested — seed runs in both
|
||||
modes); added the test. ADR-0022 Amendment 7.
|
||||
|
||||
## §3. Open issues — next session's candidates
|
||||
|
||||
Four open, all on `git.lazyeval.net/oli/rdbms-playground`. **All four
|
||||
are interaction/UX design changes that need a decision or two from the
|
||||
user up front — none is a pure mechanical fix.** Read each issue body
|
||||
**and its comments** before starting (the #33/#34 lesson).
|
||||
|
||||
- **#28 — Reconsider relationship prose in `add column` (incidental
|
||||
DDL) confirmations** *(enhancement)*. **Revisits a decided area** →
|
||||
needs a **new ADR** superseding the relevant part of ADR-0016 §5 /
|
||||
ADR-0044 §1. User preference (from the issue): do **not** show the
|
||||
`References:` / `Referenced by:` block in the add-column
|
||||
confirmation. Confirm scope with the user (just `add column`, or all
|
||||
incidental DDL). The highest-ceremony of the four.
|
||||
|
||||
- **#27 — Bottom status line: keybindings-only, context- and
|
||||
state-aware; add `mode advanced` to empty hint** *(enhancement)*.
|
||||
Per-nav-focus keybindings (Input vs sidebar), **including transient
|
||||
states** (Tab-cycle, history) per user preference. May warrant a
|
||||
small ADR. Touches `src/ui.rs` rendering + the nav-focus model
|
||||
(ADR-0046).
|
||||
|
||||
- **#29 — Command input keystroke support.** Esc / double-Esc to clear
|
||||
a partly-typed command; possibly Ctrl-A/Ctrl-E (Home/End). Relates
|
||||
to the deferred **I1b readline shortcuts** (`requirements.md`).
|
||||
**Needs a key-set decision** from the user before coding.
|
||||
|
||||
- **#30 — History brings back all commands in both modes.**
|
||||
Advanced-mode history entries can't replay in simple mode; proposal:
|
||||
if we can distinguish them, prepend `:` to reuse advanced history
|
||||
from simple mode. Interaction design; touches the input-history +
|
||||
mode model (ADR-0003).
|
||||
|
||||
No strong ordering. **#28** is the only one that *must* produce an ADR.
|
||||
**#29** is closest to "small once the key-set is decided." **#27** and
|
||||
**#30** are medium UX work.
|
||||
|
||||
## §4. Carried-over follow-up (not a `main`-branch task)
|
||||
|
||||
- **Website `seed` cast re-record** (from #34's comment thread). The
|
||||
`website` branch ships a `seed` cast exercising a `tickets` table
|
||||
with `priority`; now that `priority` collapses to `low/medium/high`,
|
||||
the cast should be re-recorded (`cd website && pnpm casts seed`,
|
||||
needs a `../target/debug` binary) so the table tightens. The issue
|
||||
comment notes it is **likely redundant** — casts get a full
|
||||
re-record sweep before publication. Tracked on the `website` branch,
|
||||
**not** here. `website/` is not in the `main` tree.
|
||||
|
||||
## §5. Other open roadmap (unchanged from handoff-67 §5)
|
||||
|
||||
`seed` is feature-complete (`requirements.md` SD1/SD2 `[x]`, now with
|
||||
the #33/#34 catalogue refinements noted inline). User's call:
|
||||
|
||||
- **H2 `hint`** — the last A1 gap (its own ADR).
|
||||
- **TT5 CI** — test infra exists; no CI workflow yet (the `ci` branch
|
||||
exists — check its state before starting).
|
||||
- **TT4 PTY (Tier-4)** — ADR-0008 specifies it; not wired.
|
||||
- Larger: **V4 journal**, **tutorial/lesson system** (each needs an ADR).
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 66 → 67 → 68, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green baseline: `cargo test` (expect 2436 pass / 1 ignored)
|
||||
+ `cargo clippy --all-targets` (clean).
|
||||
3. Pick from §3 (#28/#27/#29/#30). **For each, read the issue body AND
|
||||
its comments** before designing, and **escalate the design fork to
|
||||
the user** before coding — all four have genuine UX decisions. #28
|
||||
needs a new ADR.
|
||||
4. Follow the project workflow: phased (requirements → divergent →
|
||||
eval → execute → verify), test-first (failing test before the fix),
|
||||
`/runda` + DA pass before every commit, ADR amendment for any
|
||||
decided-area change + the README index-upkeep rule, and confirm the
|
||||
commit message with the user before committing.
|
||||
5. Consider a `cargo sweep` at this milestone (`target/` grows across
|
||||
sessions; see CLAUDE.md "Build hygiene").
|
||||
@@ -0,0 +1,203 @@
|
||||
# Session handoff — 2026-06-14 (69)
|
||||
|
||||
Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that
|
||||
closed #25/#26/#31/#32/#33/#34). This session **closed the four
|
||||
remaining open issues** — #29, #28, #27, #30 — each landed with the full
|
||||
phased workflow + `/runda` + Devil's-Advocate passes before commit, and
|
||||
each producing a new ADR. Net: **four issues closed, four commits, four
|
||||
new ADRs (0049–0052), +63 tests, zero regressions, the tracker is now
|
||||
empty.**
|
||||
|
||||
The four interlock: **#29** added the input-field readline keys, **#27**
|
||||
advertises them in a state-aware status strip, and **#30**'s history
|
||||
recall now respects modes. **#30** also turned into a real architecture
|
||||
change (journaling relocation) — read §2.4 carefully before touching that
|
||||
area.
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. Working tree **clean**; all work committed. The two
|
||||
most recent commits are local (normal working state — push is the user's
|
||||
step).
|
||||
|
||||
**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the
|
||||
long-standing `friendly` doctest). **Clippy clean** (nursery, all
|
||||
targets). Breakdown: 1771 lib + 500 integration (`it`) + 200
|
||||
typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28
|
||||
+0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the
|
||||
15 retired worker-journaling tests; trust the live `cargo test` count).
|
||||
|
||||
**Commits this session:**
|
||||
```
|
||||
4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30)
|
||||
eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27)
|
||||
8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
|
||||
66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
|
||||
```
|
||||
|
||||
**Open Gitea issues: none.** `tea issues list --state open` is empty.
|
||||
|
||||
## §2. Issues closed this session (all committed, tested, `/runda`-reviewed)
|
||||
|
||||
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
|
||||
comment.
|
||||
|
||||
### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049)
|
||||
|
||||
Implements the deferred **I1b** readline shortcuts: `Esc` clears a
|
||||
partly-typed command (only when no completion memo is alive — the memo
|
||||
wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes
|
||||
the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to
|
||||
end/start. Cursor-only keys leave history nav intact; buffer-mutating
|
||||
keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar
|
||||
nav-exit) interaction not to clear the draft — locked with a regression
|
||||
test. `requirements.md` I1b → `[x]`.
|
||||
|
||||
### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050)
|
||||
|
||||
Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/
|
||||
`rename`/`change column`, `add`/`drop index`) now render **structure
|
||||
only** — no `References:` / `Referenced by:` block. Relationship-subject
|
||||
surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044
|
||||
diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`)
|
||||
was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and
|
||||
the relationship-block half of ADR-0016 §5 (both annotated).
|
||||
|
||||
### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051)
|
||||
|
||||
The bottom status line is now keystrokes-only and **state-selected** by
|
||||
priority (sidebar focus / completion-memo / history-nav / editing /
|
||||
default). The editing state surfaces the #29 keys (closing ADR-0049's
|
||||
deferred advertisement). Mode-switch advertisements left the strip; the
|
||||
empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer
|
||||
(advanced mode shows none — user decision). New `App::is_browsing_history()`
|
||||
exposes the private `history_cursor`. 15 full-panel snapshots re-accepted.
|
||||
|
||||
### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling**
|
||||
|
||||
Closed both the feature (advanced history reusable in simple mode) and
|
||||
the bug (the `:` one-shot prefix lost across sessions). Two halves:
|
||||
|
||||
1. **Mode-tagged history.** The `history.log` status token gains an
|
||||
optional `:adv` suffix (`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; recall **strips the `:` in advanced mode**
|
||||
and keeps it in simple; hydration reconstructs the prefix from the tag.
|
||||
App commands journal simple and are excluded from the ring's advanced
|
||||
flag, so they recall bare.
|
||||
|
||||
2. **Journaling relocation (the architecture change).** Success
|
||||
journaling **moved out of the worker** to the dispatch layer
|
||||
(`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to
|
||||
the already-top-level failure journaling — so the submission mode is in
|
||||
scope with no worker plumbing. `finalize_persistence` now writes only
|
||||
the **state** sources (yaml/csv); the journal write is **best-effort**
|
||||
(the command is already committed — consistent with the failure path).
|
||||
**Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last
|
||||
scopes yaml/csv/db only), **ADR-0034** (status tag + journaling
|
||||
location), **ADR-0040** (journal-write best-effort, not fatal).
|
||||
|
||||
**Two DA findings, both resolved:** (a) the app-command `advanced` flag
|
||||
must exclude app commands (else `: save as` diverges); (b) the spawn
|
||||
journals on `outcome.is_ok()`, so journaling is now **uniform** — read
|
||||
commands that didn't journal before (`show tables`/`show relationships`/
|
||||
`show indexes`, `show relationship <name>`, `explain`) now do, matching
|
||||
ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless
|
||||
on replay — reads/`explain` don't mutate).
|
||||
|
||||
**Test migration:** 15 worker-level journaling tests were retired (the
|
||||
worker no longer journals — their yaml/csv/operation assertions were
|
||||
kept) and re-covered at the new layer: `history.rs` status-tag +
|
||||
`:`-reconstruct; `app.rs` recall matrix; the cross-session regression
|
||||
`advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple`
|
||||
in `iteration6_resume_history`; the replay tests cover `run_replay`
|
||||
journaling.
|
||||
|
||||
Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`.
|
||||
|
||||
## §3. Next session — start here
|
||||
|
||||
The user's stated plan for the next session, in order:
|
||||
|
||||
1. **Pick up the ADR-0052 follow-up** (below).
|
||||
2. **Check for any newly-filed open issues** (`tea issues list --state
|
||||
open`) — none at handoff, but check fresh.
|
||||
3. **Then** take on remaining open tasks from the general requirements
|
||||
(`docs/requirements.md`) — see §5.
|
||||
|
||||
### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing
|
||||
|
||||
When journaling moved out of the worker, the `source` that the worker
|
||||
threaded purely for journaling became dead. To avoid orphaning the param
|
||||
across ~28 handlers, the refactor **left it in place** as vestigial:
|
||||
|
||||
- `finalize_persistence(conn, persistence, _source, changes)` — the
|
||||
`_source` param is now unused (kept so its ~28 callers still pass
|
||||
`source`, which they otherwise also use for `snapshot_then`).
|
||||
- `do_rebuild_from_text(conn, _persistence, _source, project_path)` —
|
||||
both `_persistence` and `_source` vestigial.
|
||||
- Three thin read-only wrappers in `db.rs` —
|
||||
`do_describe_table_request`, `do_query_data_request`,
|
||||
`do_run_select_request` — now just delegate to their non-`_request`
|
||||
twin (`do_describe_table` / `do_query_data` / `do_run_select`) with
|
||||
vestigial `_persistence` / `_source` params and one caller each
|
||||
(`db.rs` Request arms ~2409 / ~2749 / ~2759).
|
||||
|
||||
**The cleanup:** remove `_source` from `finalize_persistence` + drop the
|
||||
arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so
|
||||
only the `finalize_persistence(...)` call loses the arg); remove the
|
||||
`_persistence`/`_source` params from `do_rebuild_from_text`; and inline
|
||||
the three `*_request` wrappers at their single call sites (replace
|
||||
`do_describe_table_request(conn, persistence, source, name)` with
|
||||
`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely
|
||||
mechanical, compiler-guided, no behaviour change. Establish the green
|
||||
baseline first (`cargo test`), then verify nothing moved.
|
||||
|
||||
## §4. Carried-over follow-up (website branch, not `main`)
|
||||
|
||||
- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still
|
||||
tracked on the `website` branch, not here. Likely redundant (full
|
||||
re-record sweep before publication).
|
||||
|
||||
## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3)
|
||||
|
||||
With the issue tracker empty, the next work comes from the document-based
|
||||
requirements. Open / partial items worth weighing (the user picks):
|
||||
|
||||
- **H2 `hint`** — the last A1 gap (contextual help for the current
|
||||
command); its own ADR. (`requirements.md` H2.)
|
||||
- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet
|
||||
(a `ci` branch reportedly exists — check its state first). Couples with
|
||||
**D1–D3** (cross-platform prebuilt binaries + Homebrew/Scoop).
|
||||
- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four
|
||||
critical flows; still not wired (no PTY deps/tests).
|
||||
- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and
|
||||
**I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command).
|
||||
- **V4 session journal** — scrollable per-session log + Markdown export
|
||||
(the bigger UX project; own ADR).
|
||||
- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged
|
||||
in scope).
|
||||
- Smaller partials: **C3a** modify relationship (drop+add covers it
|
||||
today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-***
|
||||
performance/visual targets (mostly unmeasured), **N4** global rolling
|
||||
history (OOS for v1).
|
||||
|
||||
No strong ordering — these are the user's call. Several need a new ADR
|
||||
(H2, V4, TU1); CI/release (TT5/D1–D3) is the most "shippable-product"
|
||||
track if that's the priority.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**)
|
||||
+ `cargo clippy --all-targets` (clean).
|
||||
3. `tea issues list --state open` — pick up anything new first.
|
||||
4. Then the ADR-0052 follow-up (§3), then requirements (§5).
|
||||
5. Follow the project workflow: phased (requirements → divergent → eval →
|
||||
execute → verify), test-first, `/runda` + DA pass before every commit,
|
||||
ADR amendment for any decided-area change + the README index-upkeep
|
||||
rule, and confirm the commit message with the user before committing.
|
||||
6. Consider a `cargo sweep` at this milestone (`target/` grows across
|
||||
sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was
|
||||
removed this session.)
|
||||
@@ -0,0 +1,165 @@
|
||||
# Session handoff — 2026-06-15 (70)
|
||||
|
||||
Seventieth handover. Continues from handoff-69 (which closed the last
|
||||
four Gitea issues and left the tracker empty). This session did the
|
||||
**ADR-0052 follow-up** (unwinding vestigial worker `source` plumbing),
|
||||
then **designed and fully implemented H2 — the contextual `hint`
|
||||
command + F1 keybinding (ADR-0053)** end to end (Phases A–D). The CI
|
||||
branch was also merged into `main` mid-session (not my work — see §5).
|
||||
|
||||
Net: **2 feature areas shipped, 1 new ADR (0053) + 1 ADR amendment
|
||||
(0052), 4 new Gitea issues (#35–#38), the `hint` corpus (~57 teaching
|
||||
blocks), and A1 + H2 closed in `requirements.md`.**
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. Working tree **clean**; all work committed. Commits
|
||||
are local (push is the user's step).
|
||||
|
||||
**Tests: 2499 passing / 0 failing / 0 skipped / 1 ignored** (the
|
||||
long-standing `friendly` doctest). **Clippy clean** (nursery, all
|
||||
targets). Breakdown: 1799 lib + 500 `it` + 200 typing-surface-matrix.
|
||||
|
||||
**Open Gitea issues (4, all enhancement, all filed this session):**
|
||||
- **#35** — enforce `cargo fmt` across the codebase (single reformat +
|
||||
CI gate). The tree is *not* fmt-clean (~1800 pre-existing diffs); do it
|
||||
once, coordinated with CI, before first publication.
|
||||
- **#36** — `help` collapses advanced-SQL forms onto their simple sibling
|
||||
(a `help`-list dedup artifact); they deserve distinct help content.
|
||||
- **#37** — `hint` clause-concept hints (`on delete` actions, constraint
|
||||
slots, `with pk`, cardinality) — a deferred `hint.concept.<topic>`
|
||||
layer.
|
||||
- **#38** — `hint` pre-submit-diagnostic route + the ~33 `diagnostic.*`
|
||||
tier-3 blocks (deferred; `Diagnostic` carries no class key).
|
||||
|
||||
## §2. ADR-0052 follow-up — vestigial worker `source` unwind (`e8fa859`)
|
||||
|
||||
The first task from handoff-69 §3. ADR-0052 moved success-journaling out
|
||||
of the worker, leaving the `source` that handlers threaded purely for the
|
||||
old `history.log` write dead. **Bigger than the handoff estimated** (it
|
||||
framed it as ~28 call-site edits): the cascade ran through ~30 worker
|
||||
handlers + the `DescribeTable`/`QueryData`/`RunSelect` request fields +
|
||||
their `DatabaseHandle` methods (~164 mostly-test call sites). Fully
|
||||
unwound, compiler-guided, **no behaviour change** (journaling uses a
|
||||
`source_for_journal` clone at the spawn, independent of the worker). The
|
||||
only worker `source` left is the snapshot/undo label. Amended ADR-0052
|
||||
*Consequences* + README. (Two scope forks escalated + user-approved.)
|
||||
|
||||
## §3. H2 — contextual `hint` (ADR-0053), Phases A–D — **shipped**
|
||||
|
||||
The bulk of the session. ADR-0053 settles the `hint` slot ADR-0003 left
|
||||
"ADR pending"; **closes A1** (all 15 app commands now exist) and
|
||||
**requirements H2**. Read ADR-0053 before touching this area — it went
|
||||
through three revisions and several user decisions.
|
||||
|
||||
### The design (all user-chosen)
|
||||
- **Two surfaces:** an **F1 keybinding** → tier-3 hint for the *live*
|
||||
partial input (read-only overlay — never touches buffer/cursor/memo);
|
||||
a submitted **`hint` command** → expands on the *most recent runtime
|
||||
error*. No topic arg (contextual only; `help <topic>` owns reference).
|
||||
- **Tier-3 teaching layer** beneath the existing tier-1 (colour / error
|
||||
headline) and tier-2 (ambient one-liner; the error `hint:` shown **by
|
||||
default** since `Verbosity::Verbose` is the default). Each block is
|
||||
`what` / `example` / `concept`, rendered as a `Hint` heading + aligned
|
||||
labels.
|
||||
- **Per-form keying** (Phase-B revision — the original per-node `hint_id`
|
||||
was too coarse for multi-form commands like `add`/`drop`/`show`): a new
|
||||
**`hint_ids: &[&str]`** field on `CommandNode` mirroring `usage_ids`,
|
||||
resolved by `hint_key_for_input_in_mode` (reuses `usage_key`'s
|
||||
form-word disambiguation + a mode-primary fallback for shared entry
|
||||
words so advanced `insert` → `sql_insert`, simple → `insert`).
|
||||
- **Comprehensive for v1 = command forms + 9 runtime error classes**
|
||||
(the ~33 `diagnostic.*` classes were **deferred**, #38 — see §4).
|
||||
|
||||
### Key files
|
||||
- `src/dsl/command.rs` — `AppCommand::Hint`.
|
||||
- `src/dsl/grammar/app.rs` — `HINT` node + `build_hint`.
|
||||
- `src/dsl/grammar/mod.rs` — the `hint_ids` field, `hint_key_for_input_in_mode`,
|
||||
the factored `pick_form_key`, and the two **comprehensiveness coverage
|
||||
tests** (every node has a resolving `hint.cmd.*`; every runtime error
|
||||
class has a `hint.err.*`).
|
||||
- `src/app.rs` — F1 arm in `handle_key` (read-only overlay, placed before
|
||||
the completion-memo clear); `note_hint_for_input` / `note_hint_for_recent_error`
|
||||
/ `note_getting_started` / `emit_tier3_block`; `last_error_hint_key`
|
||||
state (set in `handle_dsl_failure`, cleared in `submit` for DSL
|
||||
commands).
|
||||
- `src/friendly/translate.rs` — `error_hint_class` (maps a `DbError` +
|
||||
ctx to its `hint.err.<class>`; mirrors `translate`'s dispatch — keep in
|
||||
sync, unit-tested).
|
||||
- `src/friendly/strings/en-US.yaml` + `keys.rs` — the corpus under
|
||||
`hint.cmd.<form>` / `hint.err.<class>` + `hint.block.*` labels +
|
||||
`shortcut.hint`.
|
||||
- `src/ui.rs` — ADR-0051 strip advertises **F1** (editing + default
|
||||
states); 12 full-panel snapshots re-accepted.
|
||||
|
||||
### Phases (one commit each unless noted)
|
||||
- **A** (`050b363`) skeleton + tier-2 fallback; **B** (`4a5fd1b`) per-form
|
||||
keying + 3 exemplars; **C** content in 5 batches (`4bdfce6` app,
|
||||
`6429b56` DDL, `9c4d520` DML, `97970f2` advanced-SQL, `b6b98ad` runtime
|
||||
errors) + `417cbc8` diagnostic deferral; **D** (`447112b`) coverage gate
|
||||
+ F1 strip + status flips; **/runda fix** (`329adfc`) — see §3.1.
|
||||
|
||||
### 3.1 — what the final `/runda` caught (don't skip)
|
||||
Per-batch substring tests masked a **presentation gap**: `emit_tier3_block`
|
||||
was emitting three *bare, unlabelled* lines, deviating from the approved
|
||||
exemplar format. Fixed to render a `Hint` heading + aligned `What:` /
|
||||
`Example:` / `Concept:` lines, **locked by an `insta` snapshot**
|
||||
(`hint_block_insert`). Also confirmed the `Next:` line (ADR D2 exemplar)
|
||||
is correctly **omitted** — tier-2 ambient already owns live
|
||||
position-awareness. Lesson for the next content/UI work: **add a rendered
|
||||
snapshot early**; substring asserts don't see layout.
|
||||
|
||||
## §4. Deferrals (all tracked, all user-confirmed)
|
||||
|
||||
- **#38 diagnostic route + `diagnostic.*` blocks** — `Diagnostic`
|
||||
(`walker/outcome.rs`) carries only its rendered `message`, not a class
|
||||
key, so the F1 diagnostic route would need a `class` field threaded
|
||||
through every diagnostic site (broad) for marginal value (tier-2
|
||||
already surfaces diagnostics; many duplicate runtime classes). F1 still
|
||||
shows the useful command block when a diagnostic is present.
|
||||
- **#37 clause-concept hints** — per-form is the right tier-3 granularity;
|
||||
clause-level concepts are a separate `hint.concept.<topic>` layer for
|
||||
later.
|
||||
- **#36 `help` advanced-SQL** — out of H2's scope (touches shipped `help`).
|
||||
|
||||
## §5. CI branch merged into `main` (not my work)
|
||||
|
||||
Mid-session the **`ci` branch was merged** (commits `47a0816`, `138e766`
|
||||
+ the `ci:`/`build:`/`docs(ci):` commits). `main` now carries a CI
|
||||
pipeline, a nix flake, and **D1 cross-platform release builds** (matrix +
|
||||
macOS), documented under a **new `docs/ci/adr/` namespace** (ci-001..003).
|
||||
Implications for the roadmap: **D1 (cross-platform binaries) is now
|
||||
substantially underway** — re-assess D1/D2/D3 status against what landed
|
||||
before treating them as open. My H2 work is layered cleanly on top (all
|
||||
green post-merge).
|
||||
|
||||
## §6. Next session — start here
|
||||
|
||||
1. **Push** (user step) — 30-odd local commits incl. the CI merge + all
|
||||
of H2.
|
||||
2. **Re-baseline the roadmap** against the merged CI work: D1/D2/D3 and
|
||||
**TT5 CI** are partly/largely done now — read `docs/ci/adr/` and the
|
||||
workflows before assuming they're open (handoff-69 §5 predates this).
|
||||
3. **#35 (cargo fmt gate)** is the natural pairing with the now-merged CI
|
||||
— the user wanted it done once, before first publication.
|
||||
4. Other `requirements.md` open items (verify against CI merge first):
|
||||
**TT4** PTY tier-4 (still unwired), **I1** multi-line input, **I5/B3**
|
||||
in-flight cancellation, **V4** session journal (own ADR), **TU1**
|
||||
tutorial system (own ADR). H2/A1 are now **done**.
|
||||
5. The H2 deferrals (#36/#37/#38) are available if the user wants to
|
||||
round out the hint/help surface.
|
||||
|
||||
## §7. How to take over
|
||||
|
||||
1. Read handoffs 68 → 69 → 70, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green: `cargo test` (expect **2499 pass / 1 ignored**) +
|
||||
`cargo clippy --all-targets` (clean).
|
||||
3. Read `docs/ci/adr/` (the merged CI work) before touching CI/release/D*.
|
||||
4. For anything in the `hint` area, read **ADR-0053** first (3 revisions
|
||||
+ deferrals #37/#38). For journaling, ADR-0052 (+ its 2026-06-14
|
||||
follow-up note).
|
||||
5. Project workflow unchanged: phased, test-first, `/runda` + DA before
|
||||
commits, ADR amendment + README index-upkeep for decided-area changes,
|
||||
confirm commit messages with the user.
|
||||
6. Consider a `cargo sweep` at this milestone (`target/` grows; see
|
||||
CLAUDE.md "Build hygiene").
|
||||
@@ -0,0 +1,120 @@
|
||||
# Session handoff — 2026-06-15 (71)
|
||||
|
||||
Short, focused handover. Continues immediately from handoff-70 (which
|
||||
shipped H2 / the contextual `hint`, ADR-0053). **A user smoke-test
|
||||
surfaced a correctness bug in the hint content, and it implicates the
|
||||
whole corpus.** This handoff exists so the next session does a
|
||||
**systematic semantic verification pass over every hint block** — context
|
||||
ran too low to do it now.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch:** `main`, clean, all committed (local; push pending). **2499
|
||||
pass / 1 ignored, clippy clean.** Open issues: #35–#38 (see handoff-70).
|
||||
H2 / ADR-0053 is *functionally* complete; the **content is not
|
||||
trustworthy** until the pass below is done.
|
||||
|
||||
## §2. The bug (confirmed)
|
||||
|
||||
`hint.cmd.create_table` (in `src/friendly/strings/en-US.yaml`) reads:
|
||||
|
||||
```
|
||||
What: Create a new table — its columns, their types, and a primary key.
|
||||
Example: create table Customers with pk id(serial), name(text), email(text)
|
||||
Concept: A table is a set of rows that share the same columns. The primary
|
||||
key uniquely identifies each row; a `serial` key numbers the rows for you.
|
||||
```
|
||||
|
||||
**This is wrong.** In the DSL, **everything after `with pk` is the
|
||||
primary-key column list** (a possibly *compound* PK, ADR-0005). So the
|
||||
example does **not** create a table with `pk=id` plus regular columns
|
||||
`name`/`email` — it creates a table whose **compound primary key is
|
||||
(id, name, email)**. Non-key columns are added *separately* with
|
||||
`add column`. The `what` ("its columns, their types") and the example
|
||||
both mislead a learner badly.
|
||||
|
||||
- **Evidence:** real test usage is `create table Orders with pk
|
||||
id(serial), CustId(int)` (a 2-column *compound PK*) and the common form
|
||||
`create table X with pk id(int)` (single-column PK only). The usage
|
||||
template `create table <Name> with pk [<col>(<type>)[, ...]]` is itself
|
||||
misleading — the `[, ...]` is the PK list, not regular columns.
|
||||
- **Correct mental model:** `create table <T> with pk <pk-cols…>` then
|
||||
`add column <T>: <name> (<type>)` for each non-key column. Confirm
|
||||
against ADR-0005 (compound PK) and ADR-0009 (DSL syntax) when fixing.
|
||||
|
||||
## §3. Root cause — why this needs a *full* pass
|
||||
|
||||
During Phase C I verified *some* examples against `parse.usage.*`
|
||||
templates and real test greps, but for others I **extrapolated** beyond
|
||||
verified syntax. For `create_table` I saw `... with pk id(int)` (single
|
||||
col) and wrongly generalised to "pk + more columns," misreading the
|
||||
`with pk` list as a column list. The examples are **syntactically**
|
||||
checked but not **semantically** — i.e. not verified to *do what the
|
||||
`what`/`concept` claims*.
|
||||
|
||||
So the corpus needs a pass that, for **every** `hint.cmd.*` and
|
||||
`hint.err.*` block, checks:
|
||||
1. the `example` parses **and runs**, and
|
||||
2. it actually demonstrates what `what`/`concept` says, and
|
||||
3. `what`/`concept` are factually true of the real behaviour.
|
||||
|
||||
**Don't trust grep+extrapolation.** Prefer: run the example in the app
|
||||
(or a Tier-3 test), or check it against the authoritative ADR.
|
||||
|
||||
## §4. The pass — how to do it (next session)
|
||||
|
||||
The corpus lives in `src/friendly/strings/en-US.yaml` under `hint.cmd.*`
|
||||
(per command form) and `hint.err.*` (per runtime error class). The
|
||||
inventory and authoritative syntax sources:
|
||||
|
||||
- **`hint.cmd.<form>`** — for each, cross-check the example against the
|
||||
matching `parse.usage.<form>` template **and** the form's ADR, and run
|
||||
it. Highest-risk (extrapolated, verify first): **DDL** — `create_table`
|
||||
(known wrong), `add_column`, `add_index`, `add_constraint`,
|
||||
`change_column`, `drop_*`, `create_m2n`; **advanced-SQL** — confirm
|
||||
each is in the supported SQL subset (`select`, `with` CTE,
|
||||
`sql_insert/update/delete`, `sql_create_table`, `sql_alter_table`,
|
||||
`sql_create_index/drop_index/drop_table`, `explain_sql`); **DML** —
|
||||
`seed` forms, `explain`, `show_*`, `update`/`delete` (`--all-rows` /
|
||||
required-WHERE wording). App commands are lower-risk (reference-style).
|
||||
- **`hint.err.<class>`** — verify the fix recipe in `example` is actually
|
||||
the right remedy and `concept` matches the engine's real behaviour
|
||||
(FK sides, `on delete` actions, check/not_null/unique semantics).
|
||||
- Relevant ADRs: 0005 (types + compound PK), 0009 (DSL syntax), 0011 (FK
|
||||
type compat), 0013 (relationships/rebuild), 0014 (data ops +
|
||||
required-WHERE), 0025 (indexes), 0028/0039 (explain), 0030–0036 (SQL
|
||||
subset), 0048 (seed). `docs/requirements.md` for scope.
|
||||
|
||||
**Suggested method:** drive the app (`/run` or a small PTY/Tier-3 harness)
|
||||
and actually execute each example; or add a test that parses+runs every
|
||||
`hint.cmd.*` example and asserts success. The latter would also be a
|
||||
durable regression guard — consider adding it as part of the pass (it
|
||||
upgrades the comprehensiveness coverage test from "a block exists" to
|
||||
"the example actually works").
|
||||
|
||||
## §5. Immediate fix ready to apply
|
||||
|
||||
`create_table` is diagnosed (§2). The corrected block should make the
|
||||
example a PK-only `create table` and move the regular columns to a
|
||||
follow-up `add column`, e.g.:
|
||||
|
||||
```
|
||||
What: Create a new table with its primary key.
|
||||
Example: create table Customers with pk id(serial)
|
||||
Concept: A table is a set of rows sharing the same columns. `with pk`
|
||||
declares the primary key (one column, or several for a compound
|
||||
key); add the other columns afterwards with `add column`.
|
||||
```
|
||||
|
||||
Apply this (and re-check `create_m2n` / `add_*` while there), but only as
|
||||
part of the systematic pass — a one-off fix risks leaving siblings wrong.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 70 → 71, `CLAUDE.md`.
|
||||
2. Confirm green: `cargo test` (2499 / 1 ignored), `cargo clippy
|
||||
--all-targets`.
|
||||
3. Do the §4 pass (consider the run-every-example test in §4). Test-first,
|
||||
`/runda` before commit, confirm the commit message with the user.
|
||||
4. Pedagogy wins — these are teaching strings; correctness and clarity
|
||||
over cleverness.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Session handoff — 2026-06-15 (72)
|
||||
|
||||
Short, focused handover. Continues from handoff-71, which asked the next
|
||||
session to run a **systematic semantic verification pass over every
|
||||
`hint` block** (handoff-70 shipped H2 / ADR-0053, but a user smoke-test
|
||||
found a wrong hint and implicated the whole corpus). **That pass is now
|
||||
done.** Four content errors fixed, a durable parse-guard added, two stale
|
||||
docs corrected. Commit `5a37437`.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch:** `main`, clean, all committed (local; **push pending** — your
|
||||
step). **2500 pass / 0 fail / 1 ignored** (the long-standing `friendly`
|
||||
doctest), **clippy clean** (nursery, all targets). The +1 vs handoff-71's
|
||||
2499 is the new guard test. Open Gitea issues unchanged: **#35–#38**.
|
||||
|
||||
## §2. The verification pass (commit `5a37437`)
|
||||
|
||||
Method: cross-checked every `hint.cmd.*` example against its
|
||||
`parse.usage.*` template, ground-truthed every concept claim against the
|
||||
authoritative ADR **and a named existing test** (not grep+extrapolation —
|
||||
the trap handoff-71 §3 warned about), and parse-validated all 49 command
|
||||
examples via a new guard.
|
||||
|
||||
### Four content errors fixed (`src/friendly/strings/en-US.yaml`)
|
||||
|
||||
| Block | Bug | Fix |
|
||||
|---|---|---|
|
||||
| `cmd.create_table` | Example `with pk id(serial), name(text), email(text)` declares a **3-column compound PK**, not a PK + regular columns. Every `with pk` column is a key member — confirmed by the grammar test comment *"Every `create table` column is a primary-key column"* (`ddl.rs`), ADR-0005. | Single-column PK + `add column` for the rest; `what`/`concept` aligned. |
|
||||
| `cmd.save` | `save as my-shop` **does not parse** — `build_save` yields `AppCommand::SaveAs` with **no inline name**; `save as` opens a path-entry modal (`iteration4b` tests). | Example → `save as`; `what` de-implied; added an accurate temp-vs-named-auto-save `concept`. |
|
||||
| `cmd.import` | Target `shop-copy` **does not parse** — the `as <target>` slot is an `IdentSource::NewName` ident that tokenises only up to the hyphen. (The zip path is a BarePath and *does* accept hyphens, hence `export my-shop.zip` is fine.) | → `shop_copy`. |
|
||||
| `err.foreign_key.child_side.concept` | Offered `on delete set null/cascade` as the remedy — but `error_hint_class` maps child_side to **insert/update** violations; `on delete` governs the **parent** direction. The tier-1 hint (line 64) correctly omits it. | Corrected: parent must exist first; clarified `on delete` is the *other* direction. |
|
||||
|
||||
### Durable guard added
|
||||
|
||||
`every_cmd_hint_example_parses_in_its_mode` (`src/dsl/grammar/mod.rs`,
|
||||
in the `hint_key_tests` module). **Catalog-driven** — it iterates
|
||||
`catalog().keys()` for `hint.cmd.*.example` rather than the REGISTRY, so
|
||||
an orphaned/mis-keyed block can't slip past; floor-asserts ≥49 examples.
|
||||
Each parses in its taught mode (advanced for the SQL surface, simple
|
||||
otherwise). It caught the `save` and `import` errors **test-first** (red
|
||||
before the YAML fix). Registered the new `hint.cmd.save.concept` key in
|
||||
`keys.rs` (the `keys_validate_against_catalog` test requires every catalog
|
||||
key be declared).
|
||||
|
||||
### Verified correct (not changed)
|
||||
|
||||
All other `cmd`/`err` blocks. Notably the guard-*concept* claims were each
|
||||
confirmed against a named runtime test, not assumed:
|
||||
`drop_column_refuses_primary_key` / `…_column_in_a_relationship`,
|
||||
`drop_table_with_inbound_relationship_errors`,
|
||||
`add_not_null_column_without_default_to_populated_table_is_refused`. The
|
||||
corrected `create_table` story stays coherent with the `Customers`-
|
||||
referencing examples (id serial PK → `add column` name/email → `insert`
|
||||
skips the auto id).
|
||||
|
||||
## §3. Docs corrected (same commit)
|
||||
|
||||
Discovered while verifying `create_m2n` (which **is** implemented —
|
||||
`db.rs::do_create_m2n_relationship` + `tests/it/m2n.rs`):
|
||||
|
||||
- **CLAUDE.md** carried two **stale "deferred" claims**, both already
|
||||
implemented. Removed/updated: (a) the at-a-glance project-format line
|
||||
said export/import (Iter 5) + `--resume`/input-history/migration (Iter
|
||||
6) were "pending" — all `[x]` in `requirements.md` (ADR-0015); (b) the
|
||||
"Things deliberately deferred" list still had the **m:n convenience
|
||||
(C4)** bullet and the same project-storage bullet. `requirements.md`
|
||||
was already correct (C4 done 2026-06-10, ADR-0045), so only a
|
||||
verification-pass note was appended to its **H2** entry.
|
||||
|
||||
## §4. Scope note — what the guard does *not* do
|
||||
|
||||
The bug class here is **semantic** (an example that parses and runs but
|
||||
misrepresents the prose — e.g. `create_table`). The guard enforces only
|
||||
the **syntactic floor**: examples parse in their mode. It backstops
|
||||
future typos/clause-drift but cannot police meaning. Semantic correctness
|
||||
of the current corpus rests on this session's review (recorded in the
|
||||
commit + requirements.md H2). A stronger-but-brittler option was offered
|
||||
to the user and **not built pending their call**: per-form assertions
|
||||
that each example resolves to the *expected command shape* (e.g.
|
||||
create_table → single-column PK). `hint.err.*` examples are fix-recipe
|
||||
prose, not runnable, so they're verified by review only — inherent.
|
||||
|
||||
## §5. Next session — start here
|
||||
|
||||
The hint corpus is now trustworthy. Open roadmap (verify against the CI
|
||||
merge first, per handoff-70 §5):
|
||||
|
||||
1. **Push** (your step) — this commit + the still-unpushed backlog from
|
||||
handoffs 70/71 (the CI merge + all of H2).
|
||||
2. **#35 (cargo fmt gate)** — the natural pairing with the merged CI; the
|
||||
user wanted it done once, before first publication. The tree is **not**
|
||||
fmt-clean (~1800 pre-existing diffs).
|
||||
3. Other `requirements.md` open items: **TT4** PTY tier-4 (unwired),
|
||||
**I1** multi-line input, **I5/B3** in-flight cancellation, **V4**
|
||||
session journal (own ADR), **TU1** tutorial system (own ADR).
|
||||
4. Hint follow-ups if wanted: **#37** clause-concept hints, **#38**
|
||||
diagnostic route + `diagnostic.*` blocks, **#36** `help` advanced-SQL.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 70 → 71 → 72, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green: `cargo test` (**2500 / 1 ignored**) + `cargo clippy
|
||||
--all-targets` (clean).
|
||||
3. For anything in the `hint` area, read **ADR-0053** first. For the
|
||||
corpus, `src/friendly/strings/en-US.yaml` (`hint.cmd.*` / `hint.err.*`)
|
||||
is the content; the guard in `src/dsl/grammar/mod.rs` is the regression
|
||||
net.
|
||||
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
|
||||
ADR amendment + README index-upkeep for decided-area changes, confirm
|
||||
commit messages with the user.
|
||||
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
|
||||
CLAUDE.md "Build hygiene").
|
||||
@@ -0,0 +1,112 @@
|
||||
# Session handoff — 2026-06-16 (73)
|
||||
|
||||
Short, focused handover. Continues from handoff-72 (which completed the
|
||||
H2 hint-corpus verification pass). This session shipped one small
|
||||
feature — a **`Ctrl-G` demo-mode alias for F1** — plus follow-on doc
|
||||
hygiene. Commit `4016c3e`.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch:** `main`, clean, all committed (local; **push pending** — your
|
||||
step; the backlog now spans the CI merge, H2, the hint-corpus fixes,
|
||||
handoffs 71/72/73, and this Ctrl-G commit). **2503 pass / 0 fail / 1
|
||||
ignored** (the long-standing `friendly` doctest), **clippy clean**
|
||||
(nursery, all targets). Open Gitea issues unchanged: **#35–#38**.
|
||||
|
||||
## §2. Why Ctrl-G (the problem)
|
||||
|
||||
Casts are recorded with **`autocast`** (ADR-0047 demo mode: `--demo` /
|
||||
`RDBMS_PLAYGROUND_DEMO`). The contextual hint overlay (ADR-0053 / H2)
|
||||
opens on **F1** — but F1 reaches the app only as a terminal **escape
|
||||
sequence** (`\eOP` / `\e[11~`), and **autocast cannot emit escape
|
||||
sequences**. So the single most teaching-relevant overlay was
|
||||
unreachable in recordings (and in presenter/teacher sessions, which also
|
||||
run `--demo`). Same wall that pushed step-captions onto `Ctrl+]` (a
|
||||
single control byte) rather than `Ctrl+!`.
|
||||
|
||||
### Chord choice — why Ctrl-G, why not Ctrl-1
|
||||
|
||||
The user's first instinct was `Ctrl-1` (mnemonic, near F1). **Not
|
||||
possible:** in a legacy terminal `Ctrl`+digit has no control byte —
|
||||
`Ctrl-1` arrives as a bare `1` (would type "1" into the buffer). The
|
||||
kitty keyboard protocol *would* encode it, but only as an escape
|
||||
sequence (the very thing autocast can't send), and this app deliberately
|
||||
does **not** push `KeyboardEnhancementFlags` (`runtime.rs` does only
|
||||
`enable_raw_mode` + `EnterAlternateScreen`). So the usable space is
|
||||
exactly **`Ctrl`+letter** (single legacy control bytes). After excluding
|
||||
taken chords (`Ctrl-C` quit, `Ctrl-O` nav, `Ctrl+]` caption,
|
||||
`Ctrl-A/E/W/K/U` readline per ADR-0049), byte-collisions
|
||||
(`Ctrl-H/I/J/M/[`), flow-control (`Ctrl-S/Q`), and likely-future
|
||||
(`Ctrl-R/P/N/Y/L/V`), **`Ctrl-G`** is the clean survivor (BEL/"abort" in
|
||||
line editors — nothing destructive to shadow).
|
||||
|
||||
## §3. What shipped (commit `4016c3e`)
|
||||
|
||||
ADR-0047 **Amendment 1**. **In demo mode only**, `Ctrl-G` aliases F1:
|
||||
|
||||
- Runs the *exact* F1 hint logic (`hint_key` guard in
|
||||
`App::handle_key`, `src/app.rs` ~line 1226 — `key.code == F(1) ||
|
||||
(self.demo_mode && Ctrl-G)`), so live-input → form hint, empty input →
|
||||
recent-error / getting-started.
|
||||
- **Badges as `[F1]`** (not `[CTRL-G]`): `demo_badge_label` maps
|
||||
`Ctrl-G → Some("[F1]")` (consulted only in demo mode — the caller
|
||||
gates). So a recorded cast is **visually identical to a real F1
|
||||
press**.
|
||||
- **Demo-gated:** the shipped keymap stays F1-only. Outside demo mode
|
||||
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
|
||||
excludes CONTROL, so no `g` is typed).
|
||||
- The keybinding strip (ADR-0051) is **not** changed — F1 stays the
|
||||
advertised key; `Ctrl-G` is a recorder aid and the badge already reads
|
||||
`[F1]`.
|
||||
|
||||
**Tests (test-first, `src/app.rs` Tier-1):** `ctrl_g_in_demo_mode_-
|
||||
aliases_f1_on_input`, `…_on_empty_input`, `ctrl_g_outside_demo_mode_-
|
||||
is_inert`, plus a `Ctrl-G → [F1]` assertion added to
|
||||
`demo_badge_label_maps_the_invisible_keys`. All confirmed red→green; the
|
||||
"inert" test passed on the pre-change code, proving the demo-gate.
|
||||
|
||||
### Using it in a cast
|
||||
With `--demo` active, send **`Ctrl-G`** in the autocast script wherever
|
||||
you want the hint overlay to appear; the viewer sees the `[F1]` badge.
|
||||
|
||||
## §4. Doc hygiene done alongside (same commit)
|
||||
|
||||
- **ADR-0047 file:** removed two stray `</content>` / `</invoke>` lines
|
||||
(tool-call residue accidentally committed when the ADR was authored).
|
||||
- **CLAUDE.md "Things deliberately deferred":** dropped three **stale**
|
||||
entries — **I1b** readline shortcuts (done, ADR-0049), **I3** tab
|
||||
completion, and **I4** syntax highlighting (both done; requirements.md
|
||||
even carries a 2026-06-07 reconciliation note that I3/I4 were
|
||||
"shipped but marked not yet"). Only **I1** (multi-line input) remains —
|
||||
it is genuinely still open (`[ ]` in requirements.md). *(This follows
|
||||
the handoff-72 cleanup that removed the equally-stale m:n/C4 +
|
||||
project-storage entries.)*
|
||||
|
||||
## §5. Open / next (unchanged from handoff-72 §5)
|
||||
|
||||
The hint corpus is trustworthy and the cast-F1 gap is closed. Roadmap:
|
||||
|
||||
1. **Push** (your step).
|
||||
2. **#35 (cargo fmt gate)** — precondition (CI merged) is met; the user
|
||||
wants it done once, before first publication. Needs a `rustfmt.toml`-
|
||||
vs-defaults decision first; tree is ~1800 hunks dirty.
|
||||
3. Other open `requirements.md` items: **I1** multi-line input, **I5/B3**
|
||||
in-flight cancellation, **TT4** PTY tier-4 (unwired), **DOC1**/**E2**
|
||||
user docs (partial), **TT5** Windows-execution + Tier-4-in-CI, **D3**
|
||||
packaging manifests. Design-first (`[~]`): **V4** session journal,
|
||||
**TU1** tutorial, **C3a**, **V3**.
|
||||
4. Hint follow-ups if wanted: **#36** `help` advanced-SQL, **#37** hint
|
||||
clause-concepts, **#38** hint diagnostic route.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 71 → 72 → 73, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green: `cargo test` (**2503 / 1 ignored**) + `cargo clippy
|
||||
--all-targets` (clean).
|
||||
3. For demo-mode / casting, read **ADR-0047** (+ its Amendment 1); for
|
||||
the hint overlay it aliases, **ADR-0053**.
|
||||
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
|
||||
ADR amendment + README index-upkeep for decided-area changes, confirm
|
||||
commit messages with the user.
|
||||
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
|
||||
CLAUDE.md "Build hygiene").
|
||||
@@ -0,0 +1,247 @@
|
||||
# Plan — issue #30: mode-tagged history + top-of-chain journaling
|
||||
|
||||
**Status:** draft for `/runda` review (2026-06-13).
|
||||
**Issue:** #30 — advanced history reusable in simple mode (prepend `:`),
|
||||
and the bug: the `:` one-shot prefix is lost across sessions.
|
||||
**ADR:** ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040;
|
||||
references ADR-0003.
|
||||
|
||||
## 1. Goal & root cause
|
||||
|
||||
Two coupled needs, one root cause — **history entries carry no mode**:
|
||||
- **Bug:** the in-memory ring stores the raw `:select 1`, but the worker
|
||||
journals the *stripped* `select 1`, so cross-session the `:` is lost
|
||||
and the command recalls bare (unusable in simple mode).
|
||||
- **Feature:** persistent-advanced commands (`select 1` typed in advanced
|
||||
mode) can't be told apart from simple DSL, so they can't be offered
|
||||
back with a `:` in simple mode.
|
||||
|
||||
Fix: **record the submission mode per entry** (status tag `:adv`), keep
|
||||
the on-disk `source` canonical, and have **recall prepend/strip `:`** for
|
||||
the current mode.
|
||||
|
||||
## 2. The architecture insight (why this plan is shaped this way)
|
||||
|
||||
Journaling **success** lives deep in the worker: `finalize_persistence`
|
||||
(db.rs:3096-3099) writes `history.log` *inside the db transaction, before
|
||||
`tx.commit()`*, alongside yaml/csv — plus four no-op-skip sites and three
|
||||
read-only helpers. **Failure** journaling already lives at the top
|
||||
(runtime.rs:484-495, best-effort). Threading the mode *down* to the
|
||||
worker would mean ~30 `Request` variants + `Database` methods +
|
||||
`execute_command_typed` arms — because the journal write is far from
|
||||
where the mode is known.
|
||||
|
||||
So instead: **move success journaling up to the dispatch layer**, next to
|
||||
where failure journaling already is and where mode + outcome + source are
|
||||
all in scope. The mode then needs no plumbing. This is the correct
|
||||
separation anyway — `history.log` is an append-only *journal of what was
|
||||
typed*, not *state*; the state sources (yaml/csv/db) stay atomic in the
|
||||
worker.
|
||||
|
||||
### Semantic changes this entails (must be vetted)
|
||||
|
||||
1. **history.log leaves the worker transaction** (amends ADR-0015 §6).
|
||||
`commit-db-last` still governs yaml/csv/db (the state); the journal is
|
||||
written *after* the worker replies (i.e. after `tx.commit`), at the
|
||||
dispatch layer.
|
||||
2. **Success-journal write failure: fatal → best-effort** (amends
|
||||
ADR-0040). Today a failed `history.log` write on a *successful*
|
||||
command rolls the command back and shows a fatal banner. After: the
|
||||
command stays committed; the journal write is best-effort (logged +
|
||||
ignored), exactly like the failure path already is. The two journal
|
||||
paths become *consistent*.
|
||||
3. **Consequence:** on a rare journal-write failure (disk full /
|
||||
permissions) a successful command is applied but may be missing from
|
||||
`history.log` — not recallable next session, not replayable. The state
|
||||
(yaml/csv/db) is unaffected and consistent. This is a graceful
|
||||
degradation, not corruption, and is logged. (Today the same disk-full
|
||||
instead kills the app mid-command.)
|
||||
|
||||
**Open question for review/user:** is trading "fatal on journal-write
|
||||
failure" for "best-effort, command still succeeds" acceptable? The plan
|
||||
assumes **yes** (a journal is auxiliary; killing the app over it is worse
|
||||
UX). If not, journaling must stay coupled in the worker and we pay the
|
||||
~30-site mode plumbing instead.
|
||||
|
||||
## 3. On-disk format (mode tag in status — already chosen + partly built)
|
||||
|
||||
Record stays `<ts>|<status>|<source>`; the **status token** gains an
|
||||
optional `:adv` suffix (ADR-0052). `source` stays canonical so replay is
|
||||
unaffected.
|
||||
|
||||
| Submission | Success | Failure |
|
||||
|---|---|---|
|
||||
| Simple / app command | `ok` | `err` |
|
||||
| Advanced (SQL, persistent or one-shot) | `ok:adv` | `err:adv` |
|
||||
|
||||
**Done already** (history.rs / mod.rs):
|
||||
- `status_token(base, advanced)`, `parse_status(status) -> (is_ok, advanced)`.
|
||||
- `parse_record_source` reconstructs `": {cmd}"` for `:adv` records.
|
||||
- `parse_journal_record.status_is_ok` via `parse_status` (so `ok:adv` replays).
|
||||
- `append_history(text, advanced)`, `append_history_failure(text, advanced)`.
|
||||
|
||||
Back-compat: old `ok`/`err` logs → simple; nothing migrates.
|
||||
|
||||
## 4. In-memory ring & recall (app.rs) — the #30 behaviour
|
||||
|
||||
The ring stays `Vec<String>`. An **advanced** entry is stored in its
|
||||
`: `-prefixed simple-mode runnable form (matching the existing in-session
|
||||
one-shot ring); a **simple** entry bare. A leading `:` unambiguously
|
||||
marks advanced (simple DSL can never start with `:`).
|
||||
|
||||
- **`submit`** (app.rs:1704): compute `effective_input` + `submission_mode`,
|
||||
parse once for the app-command check (already done at 1751), then build
|
||||
the ring line. The **`advanced` flag excludes app commands** —
|
||||
`advanced = submission_mode.is_advanced() && !is_app_command` — because
|
||||
app commands (`undo`, `mode …`, `save as`, …) run in *any* mode and must
|
||||
**not** get a `:` on recall. Ring line: `": " + effective_input` if
|
||||
`advanced`, else `effective_input`; `push_history(&ring_line)`. (Today it
|
||||
pushes the raw `trimmed` *before* stripping; the reorder also drops a
|
||||
bare `:`, which executed nothing, and is what lets the app-command check
|
||||
precede the push.) `ExecuteDsl.source` stays the **canonical**
|
||||
`effective_input`.
|
||||
- *Why the app-command exclusion matters (DA finding):* without it,
|
||||
`: save as foo` (an app command via the one-shot) would store `: save
|
||||
as foo` in the ring but journal `save as foo` (app commands journal
|
||||
simple at their own sites, §5) — the very in-session-vs-cross-session
|
||||
divergence #30 is fixing, re-introduced for app commands. Excluding
|
||||
them keeps ring and disk agreeing (both bare).
|
||||
- **`history_back` / `history_forward`**: after cloning the stored entry
|
||||
into `self.input`, strip a leading `:` **iff `self.mode == Advanced`**
|
||||
(so an advanced entry runs as bare SQL in advanced mode, and as `: …`
|
||||
one-shot in simple mode). A small helper `recall_display(stored)`.
|
||||
- `seed_history` / `ProjectSwitched` payload: **unchanged** (`Vec<String>`);
|
||||
hydration already returns the `: `-prefixed form (§3).
|
||||
|
||||
Recall matrix:
|
||||
| entry \ current mode | Simple | Advanced |
|
||||
|---|---|---|
|
||||
| advanced (`: select 1`) | `: select 1` (one-shot) | `select 1` (SQL) |
|
||||
| simple (`create …`) | `create …` | `create …` |
|
||||
|
||||
## 5. Move success journaling worker → dispatch layer
|
||||
|
||||
**Remove** (worker stops journaling success):
|
||||
- `finalize_persistence` history write (db.rs:3096-3099). Keep yaml/csv.
|
||||
The now-unused `source` param: remove it + drop the arg at its ~30
|
||||
callers (mechanical, compiler-guided). (Handlers keep their own
|
||||
`source` for `snapshot_then`.)
|
||||
- The 4 no-op-skip `append_history` (db.rs:2267, 2311, 2524, 2560) — these
|
||||
outcomes (`SchemaSkipped` etc.) are `Ok` at the dispatch layer, so the
|
||||
new top-level journal covers them.
|
||||
- The 3 read-only helper `append_history` (db.rs:8372 show table, 9996
|
||||
show data, 10014 select) — `Ok(Query)`/`Ok(ShowList)` at the top.
|
||||
|
||||
**Add** (dispatch-layer journaling, all best-effort + logged):
|
||||
- **`spawn_dsl_dispatch`** (runtime.rs ~1433): pass `project_path` in;
|
||||
after `execute_command_typed`, `if outcome.is_ok() {
|
||||
Persistence::new(path).append_history(&source_for_journal,
|
||||
submission_mode.is_advanced()) }`. (Failures stay in the existing path,
|
||||
§6 — no double-journal, since Ok and Err are exclusive.)
|
||||
- **`run_replay`** (runtime.rs ~2540): after each line's
|
||||
`execute_command_typed`, `if outcome.is_ok() { append_history(
|
||||
&command_text, false) }` — replay is mode-agnostic, journalled
|
||||
**simple**. (Preserves ADR-0034 §3 "replayed sub-commands land in
|
||||
history"; a replayed advanced command re-journals without `:adv` — a
|
||||
documented OOS, not a regression: today it re-journals as plain `ok`.)
|
||||
- **`spawn_rebuild`** (runtime.rs ~503): after a successful rebuild,
|
||||
`append_history("rebuild"/source, false)`. (Rebuild journalled via
|
||||
`finalize_persistence` today; that write is gone, so add it here.)
|
||||
|
||||
**Unchanged** (already at the dispatch layer, app commands):
|
||||
- `perform_switch` (974: save-as/load/new) and `spawn_export` (1043) —
|
||||
already best-effort `append_history(&source)`; add the new `advanced`
|
||||
arg as `false` (app commands run in any mode → no `:` needed on recall;
|
||||
this also fixes the would-be "redundant `: undo`" — app commands
|
||||
journal **simple** because they're dispatched here, never via
|
||||
`ExecuteDsl`/the spawn).
|
||||
- `undo`/`redo`/`copy`/`help`/`quit`: not journalled today; unchanged.
|
||||
- The **`replay` command itself**: dispatched as `Action::Replay`, never
|
||||
reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion
|
||||
without extra work); nested `replay` skip in `run_replay` unchanged.
|
||||
|
||||
### DA-confirmed design choice: split, don't unify
|
||||
|
||||
Success journals in the spawn (`Ok` arm); **all** failures stay in the
|
||||
existing App→`JournalFailure`→runtime path (just gaining the mode).
|
||||
Considered and rejected: moving worker-rejection failures into the spawn
|
||||
too (to "unify"). It doesn't actually unify — parse failures never reach
|
||||
the spawn, so they'd stay in the App path regardless — and it adds a
|
||||
double-journal hazard (must also strip the App's `DslFailed`→
|
||||
`JournalFailure` emission). The split keeps the failure path **untouched
|
||||
in structure** (lowest risk); `Ok`/`Err` are exclusive so there is no
|
||||
double-journal. **Verified safe:** undo/redo never touches `history.log`
|
||||
(the snapshot copies db+yaml+csv only, undo.rs:15-16), and `snapshot_then`'s
|
||||
redo-clear keys on `source.is_some()`, independent of journaling — so
|
||||
removing the worker journal write does not perturb undo/snapshot at all.
|
||||
|
||||
## 6. Failure journaling — add the mode (location unchanged)
|
||||
|
||||
Keep both failure origins where they are (best-effort, dispatch/App
|
||||
layer); thread the mode so they tag `err:adv`:
|
||||
- **`Action::JournalFailure`** (action.rs:42): add `advanced: bool` (or
|
||||
`submission_mode`).
|
||||
- **`AppEvent::DslFailed`** (event.rs): add `submission_mode` (the
|
||||
worker-rejection path — the App can't recover the mode from an async
|
||||
reply otherwise).
|
||||
- **App**: the parse-failure path (`dispatch_dsl` Err arm) has
|
||||
`submission_mode` directly; the `DslFailed` handler reads it off the
|
||||
event. Both emit `JournalFailure { source, advanced }`.
|
||||
- **runtime.rs:492**: `append_history_failure(&source, advanced)`.
|
||||
|
||||
## 7. Tests
|
||||
|
||||
- **history.rs (Tier-1):** `status_token`/`parse_status` round-trip;
|
||||
`read_recent_sources` reconstructs `": …"` for `:adv` and leaves
|
||||
`ok`/`err` bare; `status_is_ok` true for `ok` & `ok:adv`; old-log
|
||||
back-compat.
|
||||
- **app.rs (Tier-1):** advanced submission stored `: `-prefixed; recall
|
||||
prepends in simple / strips in advanced; simple bare in both; bare `:`
|
||||
not stored; a parse-failure is still recallable; dedup/cap hold.
|
||||
- **iteration6_resume_history (Tier-3) — headline regression:** journal
|
||||
an advanced command (`append_history(text, true)`), hydrate, recall in
|
||||
simple → `: …`; and the full bug repro through `submit` + journal +
|
||||
hydrate if feasible.
|
||||
- **replay_command (Tier-3):** replayed commands still land in
|
||||
history.log (now via `run_replay`'s call); the `replay`-self-exclusion
|
||||
+ nested-skip still hold; advanced lines replay (status `ok:adv`
|
||||
treated as ok).
|
||||
- **Journaling relocation:** a success no longer fatals on a journal
|
||||
write failure (best-effort) — if cheaply testable; at minimum a worker
|
||||
test that previously asserted worker-side journaling is updated/removed.
|
||||
- **Update mechanical call sites:** `append_history(_, advanced)` /
|
||||
`append_history_failure(_, advanced)` at the db.rs inline tests
|
||||
(8372/9996/10014/11324 — likely now removed with the production sites),
|
||||
iteration6 (144-170), mod.rs (600).
|
||||
|
||||
## 8. ADR work
|
||||
|
||||
- **ADR-0052 (new):** the #30 feature + bug, the status-tag format, the
|
||||
`: `-prefixed ring + recall, AND the journaling relocation (it's the
|
||||
enabling refactor). Forks: status-tag format; unified scope;
|
||||
dispatch-layer journaling (best-effort).
|
||||
- **ADR-0015 §6 amendment:** history.log out of the worker transaction;
|
||||
commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer
|
||||
best-effort side-record.
|
||||
- **ADR-0034 amendment:** journaling location (dispatch layer);
|
||||
status-field `:adv` extension (it already reserved the field).
|
||||
- **ADR-0040 amendment:** a success-path journal-write failure is no
|
||||
longer fatal — best-effort, consistent with the failure path.
|
||||
- README index upkeep for every ADR touched.
|
||||
|
||||
## 9. Risks / watch-list
|
||||
|
||||
- **Double-journaling**: ensure Ok→spawn and Err→App-path stay exclusive;
|
||||
do NOT also leave a worker journal.
|
||||
- **Under/over-journaling vs today**: top-level "journal on every Ok"
|
||||
must match today's "journal every command with a source" — verified:
|
||||
reads + skips are Ok outcomes, internal ops never reach the spawn.
|
||||
- **finalize_persistence source-param removal**: 30 mechanical call-site
|
||||
edits; compiler-guided.
|
||||
- **Replay re-journal mode fidelity**: replayed advanced commands
|
||||
re-journal as simple (OOS, not a regression).
|
||||
- **best-effort journal**: rare write-failure leaves a command unjournaled
|
||||
(logged). User decision (§2 open question).
|
||||
- **app-command mode**: journalled simple by construction (dispatched
|
||||
outside the spawn) — this is correct (they run in any mode), and
|
||||
resolves the earlier "redundant `: undo`" worry.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2)
|
||||
|
||||
Implements ADR-0053. Closes the last open piece of **A1** (the canonical
|
||||
app-command set) and requirements **H2**. No Gitea issue — this is
|
||||
requirements-driven work; any genuine "later" item found en route gets
|
||||
its own issue (cf. #36, already filed for the parallel `help`-side gap).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Give learners on-demand, **teaching-grade** contextual help — a *third*
|
||||
tier beneath the existing terse always-on text (tier 1) and the
|
||||
short contextual lines that are already shown (tier 2: the live ambient
|
||||
prose, and the error `hint:` which is on by default since
|
||||
`Verbosity::Verbose` is the default). Two surfaces:
|
||||
|
||||
- **F1** (read-only overlay) → a tier-3 block for the **live partial
|
||||
input**, or — on empty input — for the **most recent runtime error**.
|
||||
- **`hint`** (submitted app command) → the tier-3 block for the **most
|
||||
recent runtime error** (the buffer is empty post-submit, so it can only
|
||||
act on recent context).
|
||||
|
||||
The mechanism is small; the **content corpus is the feature** (~80
|
||||
blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7).
|
||||
|
||||
## 2. The shape of the work (why this order)
|
||||
|
||||
The mechanism and the content are separable, and the mechanism should
|
||||
land first with **graceful tier-2 fallback** so every surface works
|
||||
before any tier-3 text exists. That lets us:
|
||||
|
||||
- build + test the trigger matrix / routing / `:`-strip / read-only-
|
||||
overlay behaviour against a skeleton (TDD), then
|
||||
- pour in content in reviewable batches without re-touching the wiring,
|
||||
- and turn on the **comprehensiveness coverage test** only once the
|
||||
corpus is complete (it is red until then — by design).
|
||||
|
||||
Build order: **Phase A** (mechanism skeleton, falls back to tier-2) →
|
||||
**Phase B** (catalogue structure + the three approved exemplars) →
|
||||
**Phase C** (comprehensive content, batched) → **Phase D** (polish:
|
||||
strip advertisement, snapshots, full green).
|
||||
|
||||
## 3. Grammar: the `hint_ids` field + the `HINT` node
|
||||
|
||||
### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B)
|
||||
- Add `pub hint_ids: &'static [&'static str]` to `CommandNode`
|
||||
(`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`),
|
||||
**mirroring `usage_ids`** — *not* a per-node `Option<&str>`. The Phase-B
|
||||
exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse:
|
||||
`add`/`drop`/`show`/`create` are each one node spanning many forms, and
|
||||
a live-input hint must be specific to the typed form. Compiler forces
|
||||
every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to
|
||||
set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them.
|
||||
**Multi-form nodes list ALL their form keys** (e.g. `add` →
|
||||
`["add_column", "add_relationship", "add_index", "add_constraint"]`) so
|
||||
the form-word disambiguation resolves correctly and unauthored forms fall
|
||||
back at render rather than mis-resolving to a sibling.
|
||||
- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single
|
||||
typed form's hint stem, reusing `pick_form_key` (factored out of
|
||||
`usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation).
|
||||
- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on
|
||||
the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms
|
||||
have distinct SQL syntax and need their own block. `hint_ids` is per
|
||||
form. (The parallel `help`-side gap is issue #36; clause-concept hints
|
||||
are deferred — issue #37.)
|
||||
|
||||
### 3b. `AppCommand::Hint` + the `HINT` node
|
||||
- `AppCommand::Hint` variant (no fields — no topic arg) in
|
||||
`src/dsl/command.rs:544`.
|
||||
- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but
|
||||
with **no topic shape** (bare keyword, like `UNDO`): `entry:
|
||||
Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`,
|
||||
`grammar/app.rs:333`), `ast_builder:
|
||||
build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id:
|
||||
Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids:
|
||||
&["parse.usage.hint"]`.
|
||||
- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY`
|
||||
(`grammar/mod.rs`), beside `HELP`. (App commands are available in both
|
||||
modes via the existing mechanism.)
|
||||
|
||||
## 4. Command identification (live-input → node)
|
||||
|
||||
The F1 live-input path needs "which command form is being typed." **The
|
||||
lookup machinery already exists** — do not rebuild entry matching:
|
||||
- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>`
|
||||
(`grammar/mod.rs:811`) returns the matched node for an entry word
|
||||
(Simple-first; the caller extracts the first word of the input).
|
||||
- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`)
|
||||
already performs the **mode-aware** Simple/Advanced selection the hint
|
||||
path needs (advanced `create` → the SQL nodes, simple → the DSL node) —
|
||||
it just returns `usage_ids` rather than the node.
|
||||
- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)`
|
||||
(or a node-returning sibling of `usage_keys_for_input_in_mode`) that
|
||||
applies the same mode selection and returns the chosen node's
|
||||
`hint_id`. Mirror the existing function; don't duplicate its matching.
|
||||
- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape,
|
||||
ADR-0003) before identification so `: SELECT …` resolves to the
|
||||
advanced `SELECT` node.
|
||||
- No match (empty / unrecognised entry word) → the "getting started"
|
||||
pointer (D2).
|
||||
|
||||
## 5. F1 keybinding (read-only overlay)
|
||||
|
||||
In `App::handle_key` (`src/app.rs:1155`):
|
||||
- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the
|
||||
sidebar-nav gate (inert there, per D2), and **before** the
|
||||
"any other key clears the completion memo" fall-through (`_ =>
|
||||
self.last_completion = None`, ~line 1228) — F1 must **not** clear the
|
||||
memo or touch the buffer/cursor (D1).
|
||||
- Behaviour (the trigger matrix, D2):
|
||||
- non-empty input → `note_hint_for_input()` (the command's `hint.cmd`
|
||||
block + the live "Next:" expected-set from the walker).
|
||||
- empty input + `last_error_hint_key` set → `note_hint_for_error()`.
|
||||
- empty input + no recent error → `note_getting_started()`.
|
||||
- Returns `Vec::new()` (pure output emission, like `help`).
|
||||
- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo
|
||||
mode surfaces it (ADR-0047).
|
||||
|
||||
## 6. The two error routes (D2 / D5)
|
||||
|
||||
- **Runtime errors:** add `last_error_hint_key: Option<String>` to `App`.
|
||||
Set it where friendly errors are rendered (`runtime.rs:2615`,
|
||||
`app.rs:2424`) from the error's class key; clear on the next successful
|
||||
command. The `hint` command and empty-input F1 read it.
|
||||
- **Pre-submit diagnostics:** the F1 live-input path, when the input
|
||||
carries an under-cursor diagnostic, reads it straight from the walker
|
||||
(`input_diagnostics_in_mode`, the same source the ambient panel uses)
|
||||
and renders that diagnostic's `hint.err.<class>` block instead of (or
|
||||
alongside) the command block. No stored state.
|
||||
- Both render from `hint.err.*`.
|
||||
|
||||
## 7. Rendering: the `note_hint*` family (D4)
|
||||
|
||||
- New `App::note_hint_for_input`, `note_hint_for_error`,
|
||||
`note_getting_started` (siblings of `note_help`/`note_help_topic`,
|
||||
`app.rs:2982`/`3021`).
|
||||
- A tier-3 block is **structured** (`what` / `example` / `concept`, plus
|
||||
the live `Next:` line on the input path). The catalogue stores each part
|
||||
under sub-keys (`hint.cmd.<id>.what`, `.example`, `.concept`); the
|
||||
renderer fetches each via `t!` and lays them out as a small framed
|
||||
block.
|
||||
- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on
|
||||
`what`/`concept`/`Next`, `Neutral` on `example` so the runnable line
|
||||
stands out. Reuse `OutputLine::styled` + `push_category_three_prose`
|
||||
patterns (`app.rs:3121`).
|
||||
- **Fallback:** if a node's `hint_id` is `None` or a key is missing,
|
||||
degrade to tier-2 (ambient prose for the input path; the verbose error
|
||||
`hint:` for the error path) — never blank.
|
||||
|
||||
## 8. Catalogue + `keys.rs`
|
||||
|
||||
- New sub-namespaces under the existing top-level `hint:` in
|
||||
`src/friendly/strings/en-US.yaml`: `hint.cmd.<hint_id>.{what,example,
|
||||
concept}` and `hint.err.<class>.{what,example,concept}`.
|
||||
- Register every key + its placeholders in `src/friendly/keys.rs`
|
||||
(`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them.
|
||||
- `parse.usage.hint` + `help.app.hint` strings for the command itself.
|
||||
|
||||
## 9. Content (Phase C — the bulk, batched per D7)
|
||||
|
||||
Exemplars approved in the ADR (`insert` live-input, FK child-side error,
|
||||
`add relationship`) are the template. Author in reviewable batches:
|
||||
1. **App commands** (~16): save/save as/load/new/rebuild/export/import/
|
||||
replay/undo/redo/mode/messages/copy/help/hint/quit.
|
||||
2. **DDL** (simple): create table, create m:n, add column/relationship/
|
||||
index, drop, rename, change column.
|
||||
3. **DML** (simple): insert, update, delete, show, seed, explain,
|
||||
select/with.
|
||||
4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE,
|
||||
CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL —
|
||||
**own blocks, SQL-syntax examples**.
|
||||
5. **Runtime error classes** (9): unique, foreign_key ×{child,parent},
|
||||
not_null, check, type_mismatch, not_found, already_exists, generic,
|
||||
invalid_value.
|
||||
6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc.
|
||||
|
||||
Each block: `what` (1–2 sentences), `example` (one runnable line,
|
||||
mode-correct), `concept` (the relational idea — the teaching part;
|
||||
optional only where genuinely none, e.g. `quit`).
|
||||
|
||||
## 10. Tests
|
||||
|
||||
Written test-first against the Phase-A skeleton where possible.
|
||||
|
||||
- **Tier 1 (unit, `app.rs`):**
|
||||
- trigger matrix: F1 non-empty → command block; F1 empty + recent error
|
||||
→ error block; F1 empty + none → getting-started; `hint` command +
|
||||
error → error block; `hint` + none → getting-started.
|
||||
- `last_error_hint_key` set on a failing command, cleared on the next
|
||||
success.
|
||||
- routing: a pre-submit diagnostic on the input drives the diagnostic
|
||||
`hint.err`; a runtime error drives the stored-key route.
|
||||
- `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node.
|
||||
- **read-only overlay:** F1 leaves `input`, `input_cursor`, and
|
||||
`last_completion` unchanged.
|
||||
- tier-2 fallback when `hint_id`/key absent.
|
||||
- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block
|
||||
(the `insert` exemplar) so the framed layout + styling spans are locked.
|
||||
- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 →
|
||||
block appears, buffer untouched; run a failing insert → `hint` → FK
|
||||
error expansion.
|
||||
- **Comprehensiveness coverage test** (enforces D6, the key one): iterate
|
||||
`REGISTRY` and assert every node has a `hint_id` resolving to a
|
||||
`hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class
|
||||
has a `hint.err.*` block. **Red until Phase C completes** — enable
|
||||
(un-`ignore`) as the final gate.
|
||||
- `keys.rs` validation continues to guarantee every *referenced* key
|
||||
resolves.
|
||||
|
||||
## 11. Keybinding strip + discoverability (Phase D)
|
||||
|
||||
- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/
|
||||
typing state (and on the empty-input state, since F1 still does
|
||||
something there). Re-accept the affected full-panel snapshots.
|
||||
|
||||
## 12. ADR / docs
|
||||
|
||||
- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from
|
||||
"implementation pending" to implemented (with date), and update the
|
||||
README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]**
|
||||
(A1 closes when `hint` lands).
|
||||
|
||||
## 13. Risks / watch-list
|
||||
|
||||
- **Command-identification reuse.** The lookup exists
|
||||
(`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`,
|
||||
`grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id`
|
||||
variant that reuses their selection. Do **not** re-implement entry-word
|
||||
matching — mirror the existing functions.
|
||||
- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is
|
||||
~240 catalogue keys; keep the `keys.rs` registration generation tidy
|
||||
(consider a helper that registers the `{what,example,concept}` triple
|
||||
for an id).
|
||||
- **Content voice drift across batches.** Re-check each batch against the
|
||||
approved exemplars; the `concept` line is where drift (too terse / too
|
||||
advanced) creeps in. Pedagogy wins ties.
|
||||
- **F1 terminal capture.** A few terminals intercept F1; acceptable
|
||||
(it's the convention) but note it if testing surfaces it.
|
||||
- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots;
|
||||
keep that diff isolated.
|
||||
- **Coverage-test timing.** It is red through Phases A–C; gate it so CI
|
||||
isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then
|
||||
make passing it the completion criterion.
|
||||
```
|
||||
@@ -0,0 +1,193 @@
|
||||
# Plan — road to public availability (versioning + install + packaging)
|
||||
|
||||
Status: **planning** (2026-06-16). Captures the decisions taken in
|
||||
discussion plus the open questions, so the work can proceed in steps.
|
||||
The versioning piece is being implemented first and is recorded as a
|
||||
decision in **ADR-0054**; the install-script and package-manager pieces
|
||||
are roadmap items here until each is built.
|
||||
|
||||
Scope note: this repo lives on the self-hosted Gitea at
|
||||
`git.lazyeval.net` (`oli/rdbms-playground`); releases publish there
|
||||
(ADR-ci-001/003). Publication branding uses the company name **Lazy
|
||||
Evaluation Ltd → `lazyeval`**, not the personal `oli` username, for
|
||||
anything user-facing (taps, buckets).
|
||||
|
||||
---
|
||||
|
||||
## 1. Versioning + version surfaces — DECIDED (→ ADR-0054, building now)
|
||||
|
||||
- **`Cargo.toml` `version` is the single source of truth.** `--version`
|
||||
reads `env!("CARGO_PKG_VERSION")` (compile-time, no deps).
|
||||
- **Two surfaces:** a `--version` / `-V` CLI flag (prints + exits, like
|
||||
`--help`), and an in-app **`version`** app command.
|
||||
- **CI discipline:** `release.yaml` gains a guard that asserts the pushed
|
||||
tag `vX.Y.Z` equals `CARGO_PKG_VERSION`, and **fails the release on
|
||||
mismatch** — so the binary's self-reported version, the release name,
|
||||
and the downloaded asset always agree.
|
||||
- **Release ritual:** bump `Cargo.toml` → commit → tag `v<that number>`
|
||||
→ push tag. (The guard enforces it.)
|
||||
- Optional enrichment (not decided): a `build.rs` baking a git short-hash
|
||||
+ build date so non-tagged dev builds read `0.2.0 (a1b2c3d)`. Good for
|
||||
bug reports; can be added later.
|
||||
|
||||
---
|
||||
|
||||
## 2. `install.sh` (curl | sh) — DONE 2026-06-17 (ADR-0055)
|
||||
|
||||
**Shipped** `scripts/install.sh` (POSIX sh, shellcheck-clean). Verified
|
||||
end-to-end against the live public `v0.1.0` release: platform mappings
|
||||
(Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly),
|
||||
pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256
|
||||
verification (incl. a tamper-rejection check), install to
|
||||
`~/.local/bin`, PATH hint. **`install.ps1` (Windows) added 2026-06-17**
|
||||
(user chose both a one-liner *and* Scoop/winget; ADR-0055 Amendment 1):
|
||||
`irm | iex`, maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`,
|
||||
SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH —
|
||||
**written but untested from this env** (no PowerShell; validate on
|
||||
Windows). The website copy that references these commands is the
|
||||
**website branch's** job (separate agent), later.
|
||||
A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not
|
||||
added — shellcheck isn't in the flake yet; touches ADR-ci-002).
|
||||
|
||||
Original decided shape (for reference):
|
||||
|
||||
- **Hosted from the Gitea repo URL** on `git.lazyeval.net` (simplest):
|
||||
`curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh`
|
||||
(exact raw-URL form to confirm against the Gitea version).
|
||||
- **Behaviour:** POSIX `sh`; detect `uname` OS+arch → target triple
|
||||
(Linux → the **musl static** build, our universal Linux artifact);
|
||||
query the latest release via the Gitea API
|
||||
(`/repos/oli/rdbms-playground/releases/latest`) → tag → deterministic
|
||||
asset name `rdbms-playground-<tag>-<target>`; download + **verify the
|
||||
`.sha256`**; install to `~/.local/bin` (fallback `/usr/local/bin` with
|
||||
a sudo prompt); `chmod +x`; print a PATH hint if needed.
|
||||
- **macOS:** binaries are signed (see §4 signing note); a `curl`
|
||||
download does **not** apply the Gatekeeper quarantine xattr, so the
|
||||
installed binary runs without `xattr` faff.
|
||||
- **Windows:** not `curl | sh` — provide a PowerShell `install.ps1`
|
||||
(`irm … | iex`) and/or steer to Scoop/winget (§3).
|
||||
- **Security posture:** HTTPS only; in-script checksum verification;
|
||||
document the download-inspect-run alternative (`curl|sh` is a trust
|
||||
tradeoff).
|
||||
- **Deliverables we own now:** `scripts/install.sh` (+ later
|
||||
`install.ps1`); confirm releases are **publicly downloadable**; decide
|
||||
whether to also upload `install.sh` as a release asset for a stable
|
||||
link. Website copy referencing the command is the **website branch's**
|
||||
job (separate agent), later.
|
||||
|
||||
---
|
||||
|
||||
## 3. D3 — package managers (roadmap; each layers on the release assets)
|
||||
|
||||
Common thread: a manifest pointing at our checksummed assets + a
|
||||
per-release step to bump it. Ordered cheapest → most gatekept.
|
||||
|
||||
### 3a. `cargo binstall` + crates.io — PREPARED 2026-06-17 (ADR-0056)
|
||||
|
||||
**Done (prep):** crate made publish-ready (dropped `publish = false`;
|
||||
added `homepage`/`keywords`/`categories`/`exclude`; authored `README.md`
|
||||
+ `LICENSE-MIT`); `[package.metadata.binstall]` added with per-target
|
||||
overrides (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct);
|
||||
`cargo publish --dry-run` clean (913 KiB compressed). Dual license kept;
|
||||
`LICENSE-MIT` + `LICENSE-APACHE` (© Lazy Evaluation Ltd) + `CONTRIBUTING.md`
|
||||
(inbound=outbound) all in place. **Gated / remaining:** the actual `cargo
|
||||
publish` (token, irreversible) at a **new tagged release** (not 0.1.0); a
|
||||
real `cargo binstall` validation.
|
||||
|
||||
- **Bootstrapping matters (user-flagged):** `binstall` is **not** a
|
||||
built-in cargo subcommand — users must install **`cargo-binstall`**
|
||||
first (its own `curl|sh`/PowerShell installer, or
|
||||
`cargo install cargo-binstall`). **Our instructions must say this.**
|
||||
- Add `[package.metadata.binstall]` to `Cargo.toml` (pkg-url template →
|
||||
our Gitea release assets; our naming already fits).
|
||||
- **DECIDED (2026-06-17): publish to crates.io** — so the frictionless
|
||||
`cargo binstall rdbms-playground` resolves the crate, and the project
|
||||
is discoverable there. (A crates.io publish is its own small task:
|
||||
metadata completeness — description/license/repository/keywords/readme
|
||||
— and `cargo publish`; the `[package.metadata.binstall]` URL template
|
||||
points binstall at our Gitea release assets.) *(Verify current
|
||||
cargo-binstall behaviour when wiring.)*
|
||||
|
||||
### 3b. Scoop (Windows)
|
||||
- A **bucket** repo under `lazyeval` on Gitea with a JSON manifest
|
||||
(`.exe` URL + hash + `autoupdate`). Release job commits the bump.
|
||||
- Users: `scoop bucket add lazyeval <gitea-url>; scoop install rdbms-playground`.
|
||||
|
||||
### 3c. Homebrew (macOS/Linux)
|
||||
- A **company-branded tap** — `lazyeval/homebrew-tap` (on Gitea) — with a
|
||||
Ruby formula (per-arch `url` + `sha256`). Release job commits the bump.
|
||||
- Users: `brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
|
||||
then `brew install lazyeval/tap/rdbms-playground` (the explicit-URL tap
|
||||
form, since the `user/repo` shorthand assumes GitHub).
|
||||
- *"Notability bars"* = the acceptance criteria for the default
|
||||
**homebrew-core** tap (must be sufficiently popular/maintained). Our
|
||||
own `lazyeval` tap sidesteps that entirely — no review gate.
|
||||
|
||||
### 3d. winget (Windows)
|
||||
- Manifests are YAML PR'd to `microsoft/winget-pkgs` (reviewed by MS).
|
||||
- **`wingetcreate` is Windows-only** (.NET) — no good without a Windows
|
||||
runner. **Automatic path to evaluate first: `komac`** — a
|
||||
cross-platform (Rust) winget manifest creator/submitter that runs on
|
||||
our **Linux** CI. *(Verify komac's current capabilities/auth model.)*
|
||||
- **Fallback:** a manual YAML PR per release — acceptable given releases
|
||||
are infrequent (user-confirmed).
|
||||
|
||||
### Cross-cutting (3a–3d)
|
||||
- Two extra repos (tap + bucket) under `lazyeval`, with CI push
|
||||
credentials — setup TBD (user: "we'll figure that out").
|
||||
- **`cargo-dist`/"dist"** automates installers + Homebrew + CI, but is
|
||||
**GitHub-Actions/Releases-centric**; on self-hosted Gitea it won't drop
|
||||
in cleanly (installer-script generation might be reusable). Likely
|
||||
hand-roll the manifests + a small "update on release" job instead.
|
||||
*(Verify cargo-dist's current Gitea support before fully ruling out.)*
|
||||
|
||||
---
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. **crates.io:** **RESOLVED 2026-06-17 — yes, publish.** (See §3a.)
|
||||
2. **Tracking:** **RESOLVED 2026-06-17 — doc + ADR only, no Gitea
|
||||
issues.**
|
||||
3. **Release downloads public:** **CONFIRMED 2026-06-17** — the Gitea
|
||||
releases are publicly downloadable (no auth); `install.sh` relies on
|
||||
it and was verified against the live `v0.1.0`.
|
||||
|
||||
### Still open / postponed
|
||||
|
||||
- **macOS signing — CONFIRMED BUG (2026-06-16), POSTPONED by the user
|
||||
(2026-06-17)** pending the correct signing ID. Details:
|
||||
- `release-macos.yaml` does `codesign --force --sign -` (ad-hoc) and has
|
||||
**no signing scaffolding at all** (no keychain import, no secrets) —
|
||||
so a downloaded binary is *not* properly signed (user-verified).
|
||||
- **The credential the user has is the wrong type:** `Apple Development:
|
||||
Oliver Sturm (W687M898E4)` is a *development* cert (Gatekeeper won't
|
||||
trust it for distribution). Distribution needs a **`Developer ID
|
||||
Application`** cert (same format, different type). Signing under the
|
||||
company name *"Lazy Evaluation Ltd"* would need an **Organization**
|
||||
Apple Developer account; a personal account signs as "Oliver Sturm".
|
||||
- **Notarization** (required with Developer ID for non-quarantined trust
|
||||
on browser downloads): after signing, `xcrun notarytool submit`. Creds
|
||||
= an **App Store Connect API key** (Issuer ID + Key ID + `.p8`,
|
||||
recommended for CI) *or* Apple ID + app-specific password + Team ID.
|
||||
A bare CLI binary can't be *stapled* (only bundles/dmg/pkg) — Gatekeeper
|
||||
does an online check instead.
|
||||
- **Urgency caveat:** the `curl|sh` path doesn't need any of this (curl
|
||||
downloads aren't quarantined); signing matters for browser downloads
|
||||
from the releases page. Fix when the right cert + creds exist; corrects
|
||||
the ad-hoc docs once landed.
|
||||
|
||||
## Sequencing
|
||||
|
||||
1. ✅ **Version discipline** (ADR-0054) — `--version`/`-V` + `version`
|
||||
command + CI tag-match guard + tests.
|
||||
2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the
|
||||
live public release.
|
||||
3. Package managers, cheapest first:
|
||||
- ✅ **`cargo binstall` + crates.io** — *prepared* (ADR-0056);
|
||||
publish gated on a new tagged release + the token.
|
||||
- **← next:** Scoop (`lazyeval` bucket) → Homebrew (`lazyeval` tap) →
|
||||
winget (komac / manual). Two `lazyeval` repos (tap + bucket) + CI
|
||||
push creds to set up.
|
||||
4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 →
|
||||
0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four
|
||||
Linux/Windows targets build immediately. (macOS leg awaits signing.)
|
||||
+73
-15
@@ -61,11 +61,35 @@ since ADR-0027.)
|
||||
|
||||
## Distribution and install
|
||||
|
||||
- [ ] **D1** Cross-platform binaries: Linux, macOS, Windows on
|
||||
- [x] **D1** Cross-platform binaries: Linux, macOS, Windows on
|
||||
x86_64 and aarch64.
|
||||
- [ ] **D2** Single static binary, no runtime dependencies.
|
||||
*(Done 2026-06-15 — CI produces all six. The four non-macOS
|
||||
targets (Linux musl + Windows gnu/gnullvm × x86_64/aarch64) are
|
||||
cross-built from the Linux runner with `cargo-zigbuild` on a `v*`
|
||||
tag (`release.yaml`); the two `*-apple-darwin` targets build
|
||||
natively on a Tart Apple-Silicon runner via the dispatched
|
||||
`release-macos.yaml`. All uploaded to the Gitea release with a
|
||||
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
|
||||
Runtime-verified by the user: Linux x86_64, Windows aarch64, and both
|
||||
macOS targets (the `release-macos.yaml` dispatch — now triggerable
|
||||
since CI is on `main` — was run end-to-end and the binaries launch);
|
||||
Linux aarch64 + Windows x86_64 remain link-clean / valid-format
|
||||
only.)*
|
||||
- [x] **D2** Single static binary, no runtime dependencies.
|
||||
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
|
||||
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
|
||||
links libc — no mingw runtime DLLs); **macOS** links only system
|
||||
libraries (`libSystem` + the AppKit/Foundation frameworks —
|
||||
inherent on every Mac, never user-installed; the build rewrites the
|
||||
one nix-store `libiconv` path to `/usr/lib` and re-signs ad-hoc).
|
||||
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".)*
|
||||
|
||||
## TUI shell
|
||||
|
||||
@@ -147,11 +171,19 @@ since ADR-0027.)
|
||||
cursor editing and is complete on its own terms; the separate
|
||||
**multi-line** entry goal is tracked under I1, which is
|
||||
genuinely not started.)*
|
||||
- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
||||
- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
||||
as aliases for Home / End for users on keyboards without those
|
||||
keys (and for ergonomics in command-driven workflows). Likely
|
||||
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
|
||||
end), Ctrl-U (delete to start). Pending.
|
||||
end), Ctrl-U (delete to start).
|
||||
*(Done 2026-06-12 — ADR-0049, issue #29: the full set —
|
||||
Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key`
|
||||
(`src/app.rs`) with helpers `clear_input` / `delete_prev_word`
|
||||
/ `kill_to_end` / `kill_to_start`; Esc clears only when no
|
||||
completion memo is alive (the memo wins first, ADR-0022);
|
||||
cursor-only keys leave history navigation intact, kill keys
|
||||
end it; 22 Tier-1 tests. On-screen advertisement of these keys
|
||||
is issue #27's bottom-status-line work.)*
|
||||
- [x] **I2** Persistent navigable input history (project-scoped).
|
||||
*(Implemented across Iterations 2 + 6: per-command append to
|
||||
`history.log` (Iter 2); on project open, the in-memory
|
||||
@@ -242,16 +274,13 @@ since ADR-0027.)
|
||||
|
||||
## App-level commands (per ADR-0003)
|
||||
|
||||
- [/] **A1** All canonical app-level commands implemented and
|
||||
- [x] **A1** All canonical app-level commands implemented and
|
||||
available in both modes: `save`, `save as`, `load`, `new`,
|
||||
`rebuild`, `export`, `import`, `seed`, `replay`, `undo`,
|
||||
`redo`, `mode`, `help`, `hint`, `quit`.
|
||||
*(Partial: **14 of 15** implemented and available in both modes —
|
||||
`quit`/`q`, `mode simple|advanced`, `help`, `save`, `save as`,
|
||||
`load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
|
||||
`redo`, and now **`seed`** (ADR-0048 / SD1, done 2026-06-11).
|
||||
**Only `hint`** (tracked as H2) remains unregistered. A1 closes
|
||||
when H2 lands.)*
|
||||
*(Done 2026-06-15: the last command, **`hint`**, landed with H2
|
||||
(ADR-0053). All 15 canonical app commands are now registered and
|
||||
available in both modes.)*
|
||||
|
||||
## DSL data commands
|
||||
|
||||
@@ -696,7 +725,10 @@ since ADR-0027.)
|
||||
`Generator`, and full completion / highlight / validity / help /
|
||||
parse-error-pedagogy wiring. Deferred SD2 increments:
|
||||
user-defined custom generators, NULL injection, multi-locale,
|
||||
recursive parent auto-seed.)*
|
||||
recursive parent auto-seed. Later catalogue refinements:
|
||||
**#33** year-as-int (`year`/`*_year`/`published`/`founded`) and
|
||||
**#34** conventional choice sets (`priority`/`severity`/`rating`,
|
||||
`status` excluded) — ADR-0048 Amendment 1.)*
|
||||
|
||||
## Query analysis
|
||||
|
||||
@@ -782,8 +814,21 @@ since ADR-0027.)
|
||||
`returning `) still shows the raw expression first-set —
|
||||
typing-time completion already offers the right candidates
|
||||
there, so the payoff is small.
|
||||
- [ ] **H2** `hint` provides contextual help for the current
|
||||
- [x] **H2** `hint` provides contextual help for the current
|
||||
input or the most recent error.
|
||||
*(Done 2026-06-15, ADR-0053: an **F1** keybinding gives a tier-3
|
||||
teaching hint for the live partial input (read-only overlay), and a
|
||||
submitted **`hint`** command expands on the most recent runtime error.
|
||||
A new `hint.cmd.<form>` / `hint.err.<class>` catalogue tier
|
||||
(`what`/`example`/`concept`) covers every command form + the 9 runtime
|
||||
error classes, enforced by a comprehensiveness coverage test. Deferred:
|
||||
the pre-submit-diagnostic route + `diagnostic.*` blocks (#38),
|
||||
clause-concept hints (#37). **Content verified 2026-06-15 (handoff-71):**
|
||||
a semantic pass over every `hint.cmd.*`/`hint.err.*` block fixed four
|
||||
errors — `create_table` (compound-PK misread), `save` (no inline name),
|
||||
`import` (hyphen-rejecting target), and `foreign_key.child_side` (wrong
|
||||
`on delete` remedy) — and added a catalogue-driven guard test that parses
|
||||
every command example in its taught mode.)*
|
||||
- [x] **H3** `help` provides general reference and per-command
|
||||
help.
|
||||
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
|
||||
@@ -846,7 +891,10 @@ since ADR-0027.)
|
||||
exists (~55 lines, covers the WHERE-expression and
|
||||
table-creation boundaries). **Missing:** a DSL
|
||||
command-surface reference and a standalone type-system
|
||||
reference under `docs/`.)*
|
||||
reference under `docs/`. **Note (2026-06-16):** the **canonical**
|
||||
user docs now live on the **website** (ADR-website-001, deployed) —
|
||||
it covers the full feature set; the in-repo `docs/` reference pieces
|
||||
named here remain the outstanding part of DOC1.)*
|
||||
|
||||
## Testing (per ADR-0008)
|
||||
|
||||
@@ -867,8 +915,18 @@ since ADR-0027.)
|
||||
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
|
||||
wired only for the listed critical flows" — it was not wired at
|
||||
all. Genuinely deferred.)*
|
||||
- [ ] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
||||
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
||||
stable Rust.
|
||||
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
|
||||
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
|
||||
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch
|
||||
push / PR, and `release-macos` runs the suite natively on the
|
||||
**macOS** runner. **Windows is build-only** — cross-compiled, not
|
||||
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
|
||||
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
|
||||
satisfied by the flake's pinned `1.95.0` (a stable release, not
|
||||
nightly). Remaining for full TT5: a Windows execution runner and
|
||||
Tier-4 PTY in CI.)*
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# ADR-website-001: Public website and documentation site
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-04). Implementation plan:
|
||||
[`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md).
|
||||
|
||||
> History: drafted as ADR-0042, renamed to ADR-0044 (each collided with a
|
||||
> number `main` had independently assigned — H1a took 0042, compound-PK FK
|
||||
> took 0043, then relationship-visualization took 0044). Moved to the
|
||||
> website ADR namespace (`docs/website/adr/`, id **ADR-website-001**) on
|
||||
> 2026-06-10 to end the recurring cross-branch number collision: website
|
||||
> decision records now draw from their own dated sequence and never the
|
||||
> main global ADR pool (see ADR-0000 "Numbering discipline"). Content is
|
||||
> unchanged from the original draft.
|
||||
|
||||
## Context
|
||||
|
||||
RDBMS Playground is nearing its first public release and needs a public
|
||||
website that does two jobs: **attract** — a landing page that shows the
|
||||
playground in action — and **document** — a thorough, canonical reference
|
||||
for everything the playground can do.
|
||||
|
||||
The documentation-heavy surface is already implemented and verified (full
|
||||
simple- and advanced-mode command set, the ten-type vocabulary,
|
||||
relationships, constraints, indexes, EXPLAIN, undo/history/replay, projects,
|
||||
export/import, the teaching echo, clipboard, friendly errors, tab completion
|
||||
and syntax highlighting; 2151 tests passing at the time of writing). The
|
||||
site is therefore largely a presentation-and-writing effort, not a
|
||||
wait-for-features one. A grounded inventory of what is shippable now lives
|
||||
in the implementation plan.
|
||||
|
||||
Several choices here had no canonical default; they were settled during a
|
||||
planning + `/runda` pass with the user and are recorded below.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Stack — Astro 6 + Starlight + Tailwind v4.** Astro's content-first,
|
||||
zero-JS-by-default model with the Starlight docs theme fits a
|
||||
marketing-landing-plus-heavy-docs site better than the alternative
|
||||
considered, SvelteKit + Tailwind (the usual go-to here). Interactive
|
||||
pieces are added as Astro islands (Svelte or vanilla), so Svelte is still
|
||||
available where it earns its place. Tailwind v4 is wired via the official
|
||||
`@tailwindcss/vite` plugin; the `@astrojs/starlight-tailwind` plugin
|
||||
bridges Tailwind with Starlight's theming.
|
||||
|
||||
2. **Demo medium — asciinema.** Showcase sequences are recorded as
|
||||
asciinema `.cast` files (text-based, small, faithful to the full
|
||||
alternate-screen render) and embedded with `asciinema-player`. The same
|
||||
casts are reused inline in the docs — one recording format serves both
|
||||
the landing page and documentation enrichment. Recordings are produced by
|
||||
a **scripted-input driver** that types commands into a real PTY with
|
||||
viewer-friendly pacing; the app's own `history.log` **replay** (ADR-0034)
|
||||
re-executes commands without typing animation or pacing and is therefore
|
||||
suitable only for state-deterministic docs snippets, not the hero demo.
|
||||
|
||||
3. **In-page WASM playground — deferred** (OOS: **deferred**, not rejected).
|
||||
A live, type-it-yourself playground compiled from the Rust app to
|
||||
WebAssembly is desirable but is a multi-week sub-project, so it does not
|
||||
block the site. The demo section is designed with a stable seam (a single
|
||||
`Demo` component contract) so a WASM playground island can replace the
|
||||
asciinema player later with no change to call sites. Recorded boundary
|
||||
for that future work:
|
||||
- **Portable core (runs on `wasm32-unknown-unknown` largely as-is):**
|
||||
`src/dsl/*` (parser, types, grammar, walker), the pure `App::update()`,
|
||||
`ui.rs`, `theme.rs`, `friendly/*`, output rendering; an in-memory DB
|
||||
path already exists (`Connection::open_in_memory()`). `rusqlite`
|
||||
compiles to the browser target via its `ffi-sqlite-wasm-rs` feature.
|
||||
- **Native edge needing `cfg`-gated browser replacements:** the
|
||||
multi-thread Tokio runtime + the dedicated DB **worker thread**
|
||||
(ADR-0010) → current-thread/in-line async; `crossterm` terminal +
|
||||
event-stream → a browser backend (e.g. Ratzilla's DOM/Canvas) + DOM
|
||||
events; `arboard`, `zip`, file persistence (ADR-0015), file logging;
|
||||
and the rusqlite **backup-API** undo (ADR-0006) → a SQL dump/restore.
|
||||
When taken up, this becomes its own ADR + iteration plan.
|
||||
|
||||
4. **Hosting — portable static build; Cloudflare is the target (decided
|
||||
2026-06-11).** Astro 6 builds to static HTML/CSS with no adapter, so the
|
||||
output deploys equally to Cloudflare, Vercel, Netlify, or GitHub Pages — we
|
||||
stay uncoupled from any one host. **Planned pipeline: Gitea Actions →
|
||||
Cloudflare.** Cloudflare now steers new projects to **Workers (static
|
||||
assets)** over Pages; either serves the static `dist/` and needs no Astro
|
||||
adapter (the `@astrojs/cloudflare` adapter is only for SSR, which the site
|
||||
does not use). The future in-page WASM playground (§3), if it needs
|
||||
COOP/COEP headers, can get them from Cloudflare `_headers`. **CI implemented
|
||||
2026-06-15** (`.gitea/workflows/website.yaml`): a push touching `website/**`
|
||||
builds the static site with pnpm and deploys `dist/` to the Cloudflare Pages
|
||||
project **`relplay`** via `wrangler` (Direct Upload — no Git integration).
|
||||
The `--branch` label selects environment against the project's production
|
||||
branch (`main`): **`main` → production (`relplay.org`)**, **`website` →
|
||||
preview (`website.relplay.pages.dev`)**, with `staging.relplay.org` attachable
|
||||
to the `website` branch alias. The crate's CI gate (`ci.yaml`) skips
|
||||
website-only pushes; the build is pure-Node (the `.cast` files are committed,
|
||||
so no cargo). Secrets: `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
|
||||
|
||||
5. **Repo topology — monorepo.** The site lives under `website/` in the
|
||||
playground repo; the crate stays at the repo root. The repo as a whole
|
||||
moves to its public home later; site and crate travel together.
|
||||
|
||||
6. **Canonical docs home — the website.** User-facing documentation lives on
|
||||
the site. In-repo `docs/` keeps ADRs, handoffs, and development notes;
|
||||
`docs/simple-mode-limitations.md` (requirement DOC1) was a development aid
|
||||
and now *feeds* the site's content rather than competing with it. The
|
||||
sharing recipes promised by requirement E2 become a docs page.
|
||||
|
||||
7. **Documentation scope and conventions.** Document the **full supported
|
||||
feature set**. Any capability not yet fully implemented (a small minority
|
||||
— e.g. multi-line input, query cancellation, `seed`, `m:n` convenience,
|
||||
ER-diagram export, the `show tables`/`relationships`/`indexes` family) is
|
||||
either omitted or carries a clear **"planned / not yet available"**
|
||||
callout — never presented as shipped. Two wording rules bind all
|
||||
user-facing copy:
|
||||
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) — continues the
|
||||
user-facing posture of ADR-0002; copy says "the database"/"the engine".
|
||||
- **No "DSL"** — it is internal jargon. The two input modes are **simple
|
||||
mode** (the playground's keyword command language) and **advanced
|
||||
mode** (SQL).
|
||||
|
||||
8. **Install documentation — two mechanisms.** The install page documents
|
||||
**prebuilt release binaries** (self-hosted download — not GitHub
|
||||
Releases, since the repo will move) and **package managers**. Both can be
|
||||
written now against the planned mechanisms; concrete download URLs slot in
|
||||
at release. (Distribution items D1–D3 in `requirements.md` remain the
|
||||
tracking home for the release tooling itself.)
|
||||
|
||||
## Consequences
|
||||
|
||||
- The site can ship on the strength of already-implemented features; it is
|
||||
gated on writing and recording, not on finishing the app.
|
||||
- One recording format (asciinema `.cast`) serves both marketing and docs,
|
||||
and is reusable as the app evolves (re-run the script, re-record).
|
||||
- The WASM playground is preserved as a real future option without holding
|
||||
up launch; the demo seam keeps the upgrade cheap.
|
||||
- A single canonical docs home removes the divergence risk of maintaining
|
||||
user docs in two places.
|
||||
- Website build choices (Decisions 1, 2, 4, 5) are recorded here for
|
||||
traceability but do not, by themselves, warrant further ADRs; only
|
||||
app-architecture decisions (notably the future WASM port) will.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **In-page WASM playground** — *deferred* (see Decision 3); revisit as its
|
||||
own ADR + iteration plan.
|
||||
- **Hosted/SaaS playground or a server-backed doc CMS** — *rejected*: a
|
||||
static site fully satisfies the need, consistent with ADR-0007's
|
||||
no-hosted-publishing stance. Revisit only if real demand emerges.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Website Architecture Decision Records
|
||||
|
||||
Decision records for the **public website + documentation site** subproject
|
||||
(the Astro/Starlight site under `website/`). These are kept in their own
|
||||
namespace, separate from the project-wide ADRs in
|
||||
[`docs/adr/`](../../adr/README.md), so website decisions never compete with
|
||||
the main global ADR sequence for numbers — see
|
||||
[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md).
|
||||
|
||||
**Numbering.** Files are named `<date>-adr-website-<NNN>.md` and referenced
|
||||
in prose as `ADR-website-NNN`. The `<date>` (the ADR's accepted/created day,
|
||||
`YYYYMMDD`) plus the `website` segment keeps the namespace disjoint from
|
||||
`main`'s integers. Assign the next free `NNN` from this index. Every ADR
|
||||
change updates this index in the same edit (the ADR-0000 index-upkeep rule
|
||||
applies here too).
|
||||
|
||||
## Index
|
||||
|
||||
- [ADR-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **monorepo** (`website/`). **§4 update (CI implemented):** the static→Cloudflare Pages deploy now runs via Gitea Actions (`.gitea/workflows/website.yaml`; the crate gate is skipped for website-only changes); both `website` and `main` are merged and the site is **deployed**. Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1–D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md)
|
||||
@@ -0,0 +1,198 @@
|
||||
# Plan: public website and documentation site
|
||||
|
||||
**Date:** 2026-06-04 · **Status:** ready to build
|
||||
|
||||
Decisions for this work are recorded in
|
||||
[ADR-website-001](../adr/20260604-adr-website-001.md): Astro 6 +
|
||||
Starlight + Tailwind v4; asciinema demos reusable in docs; the in-page WASM
|
||||
playground deferred behind a stable demo seam; portable static hosting
|
||||
(Vercel target); monorepo (`website/`); website is the canonical docs home;
|
||||
full-feature-set docs with "planned" callouts; install docs cover prebuilt
|
||||
binaries + package managers. This plan is the *how*.
|
||||
|
||||
## Repository layout
|
||||
|
||||
The site lives under `website/` in this repo; the crate stays at the root.
|
||||
|
||||
```
|
||||
website/
|
||||
├── package.json # pnpm; astro, @astrojs/starlight, tailwind v4
|
||||
├── astro.config.mjs # Starlight integration + sidebar nav
|
||||
├── src/
|
||||
│ ├── pages/index.astro # marketing landing (custom, not Starlight)
|
||||
│ ├── components/
|
||||
│ │ ├── Demo.astro # demo SLOT — the WASM-playground seam
|
||||
│ │ └── Cast.astro # asciinema-player island wrapper
|
||||
│ ├── content/docs/ # Starlight MDX docs (the bulk of the work)
|
||||
│ └── styles/ # shared Tailwind + Starlight theme tokens
|
||||
├── public/casts/ # recorded *.cast asciinema files
|
||||
├── README.md # local dev + recording recipe
|
||||
└── STYLE.md # living documentation style guide
|
||||
```
|
||||
|
||||
Root `.gitignore` gains `website/node_modules`, `website/dist`,
|
||||
`website/.astro`.
|
||||
|
||||
## Documentation inventory (grounded — drives Phase D scope)
|
||||
|
||||
Built from `docs/handoff/55–59`, `docs/adr/*`, the command REGISTRY
|
||||
(`src/dsl/grammar/mod.rs:603`, which also auto-assembles in-app `help`), the
|
||||
`Command` enum (`src/dsl/command.rs:149`), and
|
||||
`src/friendly/strings/en-US.yaml` — **not** the coarse `requirements.md`
|
||||
checkboxes (handoff-59 found those ~46% mis-marked; they now use a `[/]`
|
||||
"partial" legend — trust the code, not the marker). Refreshed **2026-06-09
|
||||
after merging `main`**, which added the show-list/detail, `help <command>`,
|
||||
and compound-PK FK surface (see the dedicated bullet below). Test state:
|
||||
**2193 passing, 0 failing, 1 ignored.**
|
||||
|
||||
**SHIPPED — document as-is (the doc core):**
|
||||
- Input modes: simple, advanced (SQL), `:` one-shot escape, `mode` command,
|
||||
per-project mode restore (ADR-0003/0015/0037).
|
||||
- Full simple-mode command surface: create/drop table; add/drop/rename/
|
||||
change column; add/drop 1:n relationship (named, ON DELETE/UPDATE
|
||||
CASCADE/SET NULL/RESTRICT, `--create-fk`); add/drop index; insert/update/
|
||||
delete (required WHERE + `--all-rows`; complex WHERE: AND/OR/NOT, LIKE,
|
||||
IS NULL, IN, BETWEEN); show table/data (where/limit); add/drop constraint;
|
||||
explain (ADR-0009/0013/0014/0025/0026/0028/0029).
|
||||
- Full advanced-mode SQL: CREATE/DROP/ALTER TABLE (cols, constraints, inline
|
||||
+ table FKs, rename, alter-column-type), CREATE [UNIQUE]/DROP INDEX; SELECT
|
||||
(joins, GROUP BY/HAVING, ORDER BY, LIMIT/OFFSET, UNION/INTERSECT/EXCEPT,
|
||||
WITH [RECURSIVE] CTEs); INSERT (multi-row, ON CONFLICT, RETURNING)/UPDATE/
|
||||
DELETE; full expression grammar incl. CASE, CAST, curated functions;
|
||||
EXPLAIN over SQL (ADR-0030–0039).
|
||||
- Types: all ten, advanced-mode SQL aliases, serial/shortid auto-fill
|
||||
(ADR-0005/0011/0017/0018). Constraints: PK incl. compound, NOT NULL,
|
||||
UNIQUE, CHECK, DEFAULT, FK (ADR-0029/0013/0035).
|
||||
- Undo/redo, history.log journal, replay, `--resume`, `--no-undo`
|
||||
(ADR-0006/0034). Projects & storage: project.yaml + CSV + history.log,
|
||||
save/save as/load/new/rebuild, temp projects, `--data-dir`
|
||||
(ADR-0004/0015). Export/import (zip), clipboard copy/copy all/copy last
|
||||
(ADR-0007/0041).
|
||||
- Friendly errors (all five categories) + validity indicator
|
||||
(ADR-0019/0027), DSL→SQL teaching echo (ADR-0038), EXPLAIN plan tree
|
||||
(ADR-0028), box-drawing tables (ADR-0016), tab completion + syntax
|
||||
highlighting + in-line editing (ADR-0022).
|
||||
- **Added by the `main` merge (2026-06-09):** schema-inspection commands
|
||||
`show tables` / `show relationships` / `show indexes` and the singular
|
||||
`show relationship <name>` / `show index <name>` detail views (V5/V5a);
|
||||
`help [<command>]` per-command detail + `help types` + general reference
|
||||
(H3); **compound-primary-key foreign-key references** — DSL
|
||||
`from <P>.(a, b) to <C>.(x, y)` and SQL `FOREIGN KEY (a, b) REFERENCES
|
||||
P(x, y)` (single-column form unchanged) (ADR-0043, T3); friendlier
|
||||
parse-error near-miss messaging (H1a, ADR-0042). These need coverage: a
|
||||
schema-inspection page (the `show` family) and compound-FK examples on the
|
||||
Relationships page.
|
||||
|
||||
**DOCUMENT WITH CAVEAT:** `add unique index` is advanced-only; simple-mode
|
||||
table rename is intentionally absent (rename is `ALTER TABLE … RENAME TO`);
|
||||
`hint` (H2) is still partial; a compound-FK *violation* message names only
|
||||
the first column pair (enforcement is correct — a messaging-only residual).
|
||||
|
||||
**OMIT or MARK "planned":** multi-line input (I1), readline shortcuts (I1b),
|
||||
in-flight cancellation / query timeout (I5/B3), `seed` (SD1), `m:n`
|
||||
convenience (C4), one-step modify relationship (C3a), relationship line-art
|
||||
(V1), ER-diagram export (V3), session-log + Markdown export (V4).
|
||||
|
||||
**Mine verbatim for docs:** `en-US.yaml` `help.app.*`, `help.ddl.*`,
|
||||
`help.data.*`, `help.types_reference`, `parse.usage.*` (one-line syntax
|
||||
templates), `hint.*` — keeps docs and in-app help consistent.
|
||||
|
||||
## Phases
|
||||
|
||||
### A — Scaffold
|
||||
`pnpm create astro@latest` (Starlight template) in `website/`; `astro add
|
||||
tailwind` (Tailwind v4 via `@tailwindcss/vite`); add
|
||||
`@astrojs/starlight-tailwind`. Confirm `pnpm dev` serves and `pnpm build`
|
||||
emits a static `dist/`. Echo build steps for traceability.
|
||||
|
||||
### B — Landing page
|
||||
Custom `src/pages/index.astro` (Starlight owns `/docs/*`). Hero + value prop
|
||||
("learn relational databases by doing"), feature highlights from the
|
||||
inventory, an embedded demo cast above the fold. Use the `frontend-design`
|
||||
skill to avoid generic AI aesthetics; honour NFR-4/5/7 (distinctive design,
|
||||
meaningful colour, light/dark).
|
||||
|
||||
### C — asciinema recording workflow
|
||||
Record real `rdbms-playground` sessions to `public/casts/*.cast` using a
|
||||
**scripted-input driver** (e.g. `asciinema-automation`/autocast, or an
|
||||
expect/doitlive script) for paced, re-recordable demos. Record at a fixed
|
||||
sensible cols×rows; provide light + dark player themes. `Cast.astro` wraps
|
||||
`asciinema-player` as a `client:visible` island; the same component embeds
|
||||
casts inline in docs. Document the recipe in `website/README.md`.
|
||||
(`asciinema` 2.4.0 is installed.)
|
||||
|
||||
### D — Documentation (the bulk)
|
||||
**Five** top-level sidebar sections (autogenerated per directory). The key
|
||||
split: *Using the playground* = the application you drive; *Reference* = the
|
||||
database language you build with.
|
||||
- **Getting started** — install (prebuilt binaries + package managers),
|
||||
first project, simple vs. advanced mode, the example library.
|
||||
- **Using the playground** — command-line options; the assistive editor
|
||||
(completion, syntax highlighting, the `[ERR]`/`[WRN]` validity indicator,
|
||||
hints, in-line editing); the output pane (PageUp/PageDown scrolling — the
|
||||
fuller V4 session-log / Markdown export is *planned*, mark it); projects
|
||||
(save / load / new / rebuild); undo, redo & history (+ replay); export &
|
||||
import (E2 recipes); copy to clipboard; getting help (`help` /
|
||||
`help <command>` / `hint`). (ADR-0003 "app-level commands" + ADR-0022/0027
|
||||
typing assistance + the CLI.)
|
||||
- **Guides** — task walkthroughs.
|
||||
- **Reference** — the database language: Tables, Columns, Relationships,
|
||||
Indexes, Constraints, Inserting & editing data, Querying & inspecting
|
||||
(`show` / `select`), Types, Query plans (EXPLAIN), Errors explained, the
|
||||
simple-command → SQL teaching echo.
|
||||
- **Concepts** — the *why*: projects & storage model, the derived database,
|
||||
how undo works.
|
||||
|
||||
**Surface the assistive editor prominently** — it is a differentiator and
|
||||
most helps beginners: a landing-page card + a Getting-started mention, both
|
||||
linking into *Using the playground*. It is prime asciinema-cast material
|
||||
(completion / validity indicator are motion a still code block can't show).
|
||||
|
||||
Build order: Tier 1 simple-mode reference + types + constraints + input
|
||||
modes + mined help/usage strings → Tier 2 advanced SQL + relationships +
|
||||
project lifecycle + undo/history → Tier 3 teaching echo + EXPLAIN + errors +
|
||||
completion/highlighting → Tier 4 clipboard + hints + editing.
|
||||
|
||||
Conventions live in the **living style guide** `website/STYLE.md` (binding
|
||||
rules from ADR-website-001 §7 — no engine name, **no "DSL"**, "planned" callouts —
|
||||
plus finer conventions and an open-decisions log for depth/splitting/example
|
||||
dataset/etc. as they settle). Sources to mine: `src/dsl/command.rs`,
|
||||
`src/dsl/grammar/*`, the REGISTRY, `en-US.yaml`, `docs/adr/*`,
|
||||
`docs/simple-mode-limitations.md`.
|
||||
|
||||
### E — Hosting & portability
|
||||
Keep the default static build (no adapter); `dist/` deploys to Vercel or any
|
||||
static host. `website/README.md` notes the Vercel preset (root dir
|
||||
`website/`) and the one-line `@astrojs/vercel` switch if SSR is ever needed.
|
||||
|
||||
## Demo seam (WASM hook)
|
||||
|
||||
`Demo.astro` exposes a stable contract (`{ src, title, height, autoplay }`).
|
||||
At launch it renders `Cast.astro`; later a `Playground.astro` WASM island
|
||||
swaps in behind the same props on the landing page and in docs, with zero
|
||||
call-site changes. Boundary details are in ADR-website-001 §3.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm dev` renders landing + docs; `pnpm build` emits a clean static
|
||||
`dist/` with no errors/warnings.
|
||||
- Landing shows at least one playing `.cast`; the same component renders a
|
||||
cast inline in a docs page (proves reuse).
|
||||
- Starlight link-check passes (broken internal links fail the build).
|
||||
- Docs grep clean of forbidden terms: **no "DSL"**, no engine name.
|
||||
- A `dist/` static deploy works on Vercel (manual import) — confirms
|
||||
portability. (No CI gate yet, per ADR-website-001 §4.)
|
||||
|
||||
## Notes / recommendations (non-blocking)
|
||||
|
||||
- **Doc drift:** consider generating the command reference from source (the
|
||||
`help` REGISTRY / `en-US.yaml`) rather than hand-writing all of it.
|
||||
- **Accessibility/SEO:** pair each hero `.cast` with a text transcript or the
|
||||
equivalent docs snippet.
|
||||
- **Branding/domain & analytics** unspecified — assume none until decided;
|
||||
no third-party trackers without consent.
|
||||
- Tailwind v4 + Starlight have occasional theme-token friction; the
|
||||
`@astrojs/starlight-tailwind` plugin is the supported bridge.
|
||||
- Starlight ships local search (Pagefind) by default.
|
||||
- No `README.md` exists at the repo root yet — wanted for the destination
|
||||
repo; out of this plan's core scope but flagged.
|
||||
@@ -0,0 +1,94 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Download and install a prebuilt rdbms-playground binary (Windows).
|
||||
|
||||
.DESCRIPTION
|
||||
The Windows counterpart of scripts/install.sh. Detects the CPU
|
||||
architecture, downloads the matching release .exe from the Gitea
|
||||
releases, verifies its SHA-256 checksum, installs it to
|
||||
%LOCALAPPDATA%\Programs\rdbms-playground, and adds that directory to
|
||||
your user PATH.
|
||||
|
||||
Quick start:
|
||||
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
|
||||
|
||||
We ship gnu / gnullvm Windows builds (x86_64 / aarch64); this maps the
|
||||
host architecture to the right asset.
|
||||
|
||||
.PARAMETER Version
|
||||
Install a specific tag (e.g. v0.2.0) instead of the latest release.
|
||||
Defaults to $env:RDBMS_VERSION, else the latest release.
|
||||
|
||||
.PARAMETER InstallDir
|
||||
Install directory. Defaults to $env:RDBMS_INSTALL_DIR, else
|
||||
%LOCALAPPDATA%\Programs\rdbms-playground.
|
||||
|
||||
.NOTES
|
||||
Written but NOT tested on Windows from this environment (no PowerShell
|
||||
here) — validate on a real Windows host. The verified sibling is
|
||||
install.sh (Linux/macOS).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Version = $env:RDBMS_VERSION,
|
||||
[string]$InstallDir = $(if ($env:RDBMS_INSTALL_DIR) { $env:RDBMS_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\rdbms-playground" })
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Repo = 'https://git.lazyeval.net/oli/rdbms-playground'
|
||||
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
|
||||
$Bin = 'rdbms-playground'
|
||||
|
||||
# Map the host CPU to the target triple we publish for Windows.
|
||||
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
||||
switch ($osArch) {
|
||||
'X64' { $target = 'x86_64-pc-windows-gnu' }
|
||||
'Arm64' { $target = 'aarch64-pc-windows-gnullvm' }
|
||||
default { throw "install: unsupported CPU architecture: $osArch" }
|
||||
}
|
||||
|
||||
# Resolve the release tag (explicit -Version, else the latest release).
|
||||
if (-not $Version) {
|
||||
$Version = (Invoke-RestMethod -Uri "$Api/releases/latest").tag_name
|
||||
if (-not $Version) { throw 'install: could not determine the latest release tag' }
|
||||
}
|
||||
|
||||
$asset = "$Bin-$Version-$target.exe"
|
||||
$url = "$Repo/releases/download/$Version/$asset"
|
||||
|
||||
$tmp = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())
|
||||
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
|
||||
try {
|
||||
$exe = Join-Path $tmp "$Bin.exe"
|
||||
$shaFile = "$exe.sha256"
|
||||
|
||||
Write-Host "downloading $asset ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile
|
||||
|
||||
# The sidecar is "<hash> <name>"; compare just the hash.
|
||||
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
|
||||
$actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
|
||||
if ($expected -ne $actual) {
|
||||
throw "install: checksum mismatch (expected $expected, got $actual) — refusing to install"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
$dest = Join-Path $InstallDir "$Bin.exe"
|
||||
Move-Item -Path $exe -Destination $dest -Force
|
||||
Write-Host "installed $Bin $Version -> $dest"
|
||||
|
||||
# Add the install dir to the user PATH (persistent) if it's not there.
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath) { $userPath = '' }
|
||||
if (($userPath -split ';') -notcontains $InstallDir) {
|
||||
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
|
||||
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
||||
Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/bin/sh
|
||||
# install.sh — download and install a prebuilt rdbms-playground binary.
|
||||
#
|
||||
# Quick start (Linux / macOS):
|
||||
# curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
|
||||
#
|
||||
# What it does: detects your OS + CPU, downloads the matching release binary
|
||||
# from the Gitea releases (Linux uses the fully-static musl build), verifies
|
||||
# its SHA-256 checksum, and installs it to ~/.local/bin.
|
||||
#
|
||||
# Environment overrides:
|
||||
# RDBMS_VERSION install a specific tag (e.g. v0.2.0) instead of the latest
|
||||
# RDBMS_INSTALL_DIR install directory (default: $HOME/.local/bin)
|
||||
# RDBMS_OS force the OS (testing): Linux | Darwin
|
||||
# RDBMS_ARCH force the CPU (testing): x86_64 | aarch64
|
||||
#
|
||||
# Flags:
|
||||
# --print-target print the resolved target triple and exit (no download)
|
||||
# -h, --help print this help and exit
|
||||
#
|
||||
# Notes:
|
||||
# * Windows is not installable via this script (the binary is a .exe) —
|
||||
# use Scoop/winget (planned) or download the .exe from the releases page.
|
||||
# * macOS: a curl download is not quarantined by Gatekeeper, so the binary
|
||||
# runs without extra steps. (Developer-ID signing + notarization is a
|
||||
# separate, planned improvement for browser downloads.)
|
||||
#
|
||||
# POSIX sh — no bashisms, so it runs under the `sh` of `curl | sh`.
|
||||
|
||||
set -eu
|
||||
|
||||
REPO_BASE="https://git.lazyeval.net/oli/rdbms-playground"
|
||||
API_BASE="https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground"
|
||||
BIN_NAME="rdbms-playground"
|
||||
PRINT_TARGET=0
|
||||
|
||||
err() {
|
||||
printf 'install: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
info() { printf '%s\n' "$1" >&2; }
|
||||
|
||||
usage() {
|
||||
# Lines 2..(first blank) of this file are the human-readable header.
|
||||
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
# Resolve the Rust target triple for the current (or forced) platform.
|
||||
# Linux -> <arch>-unknown-linux-musl (the fully-static build)
|
||||
# macOS -> <arch>-apple-darwin
|
||||
detect_target() {
|
||||
os="${RDBMS_OS:-$(uname -s)}"
|
||||
arch="${RDBMS_ARCH:-$(uname -m)}"
|
||||
case "$os" in
|
||||
Linux | linux) os_part="unknown-linux-musl" ;;
|
||||
Darwin | darwin | macos | macOS) os_part="apple-darwin" ;;
|
||||
MINGW* | MSYS* | CYGWIN* | *Windows* | *windows*)
|
||||
err "Windows is not supported by this installer — use Scoop/winget (planned) or download the .exe from $REPO_BASE/releases" ;;
|
||||
*) err "unsupported operating system: $os" ;;
|
||||
esac
|
||||
case "$arch" in
|
||||
x86_64 | amd64) arch_part="x86_64" ;;
|
||||
aarch64 | arm64) arch_part="aarch64" ;;
|
||||
*) err "unsupported CPU architecture: $arch" ;;
|
||||
esac
|
||||
printf '%s-%s' "$arch_part" "$os_part"
|
||||
}
|
||||
|
||||
# Download $1 to file $2 (curl or wget).
|
||||
download() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$1" -o "$2"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO "$2" "$1"
|
||||
else
|
||||
err "need either curl or wget on PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch $1 to stdout (curl or wget).
|
||||
fetch() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "$1"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -qO- "$1"
|
||||
else
|
||||
err "need either curl or wget on PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
# The release tag to install: $RDBMS_VERSION if set, else the latest release.
|
||||
resolve_version() {
|
||||
if [ -n "${RDBMS_VERSION:-}" ]; then
|
||||
printf '%s' "$RDBMS_VERSION"
|
||||
return
|
||||
fi
|
||||
json=$(fetch "$API_BASE/releases/latest") ||
|
||||
err "could not query the latest release from $API_BASE"
|
||||
# Portable JSON scrape (no jq): the latest-release object carries exactly
|
||||
# one "tag_name": "<tag>" field.
|
||||
tag=$(printf '%s' "$json" |
|
||||
grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' |
|
||||
head -1 | sed 's/.*"\([^"]*\)"$/\1/')
|
||||
[ -n "$tag" ] || err "could not parse the latest release tag"
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
# Verify file $1 against sha256 sidecar $2 (format: "<hash> <name>").
|
||||
verify_checksum() {
|
||||
expected=$(awk '{print $1; exit}' "$2")
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "$1" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
actual=$(shasum -a 256 "$1" | awk '{print $1}')
|
||||
else
|
||||
info "warning: no sha256 tool found — skipping checksum verification"
|
||||
return 0
|
||||
fi
|
||||
[ "$expected" = "$actual" ] ||
|
||||
err "checksum mismatch (expected $expected, got $actual) — refusing to install"
|
||||
}
|
||||
|
||||
main() {
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--print-target) PRINT_TARGET=1 ;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) err "unknown argument: $1 (try --help)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
target=$(detect_target)
|
||||
if [ "$PRINT_TARGET" = "1" ]; then
|
||||
printf '%s\n' "$target"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
version=$(resolve_version)
|
||||
asset="$BIN_NAME-$version-$target"
|
||||
url="$REPO_BASE/releases/download/$version/$asset"
|
||||
dir="${RDBMS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
tmp=$(mktemp -d 2>/dev/null) || err "could not create a temporary directory"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
info "downloading $asset ..."
|
||||
download "$url" "$tmp/$BIN_NAME" || err "download failed: $url"
|
||||
download "$url.sha256" "$tmp/$BIN_NAME.sha256" || err "checksum download failed: $url.sha256"
|
||||
verify_checksum "$tmp/$BIN_NAME" "$tmp/$BIN_NAME.sha256"
|
||||
|
||||
mkdir -p "$dir" || err "could not create install directory: $dir"
|
||||
chmod +x "$tmp/$BIN_NAME"
|
||||
mv "$tmp/$BIN_NAME" "$dir/$BIN_NAME" || err "could not install to $dir"
|
||||
info "installed $BIN_NAME $version -> $dir/$BIN_NAME"
|
||||
|
||||
case ":${PATH:-}:" in
|
||||
*":$dir:"*) ;;
|
||||
*) info "note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -41,6 +41,11 @@ pub enum Action {
|
||||
/// §4). `source` is the original user-typed text.
|
||||
JournalFailure {
|
||||
source: String,
|
||||
/// Whether the failed submission was advanced (ADR-0052): tags the
|
||||
/// `err` record `err:adv` so a failed advanced command hydrates in
|
||||
/// its `:`-prefixed form, recallable in simple mode. App commands
|
||||
/// (mode-agnostic) are `false`.
|
||||
advanced: bool,
|
||||
},
|
||||
/// User issued the `rebuild` app-level command (ADR-0015
|
||||
/// §7, §11). Runtime computes a summary from
|
||||
|
||||
+1200
-206
File diff suppressed because it is too large
Load Diff
+34
-34
@@ -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,7 +478,9 @@ 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 {
|
||||
entry
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|source| ArchiveError::Io {
|
||||
path: dst_path.clone(),
|
||||
source,
|
||||
})?;
|
||||
@@ -523,7 +508,11 @@ mod tests {
|
||||
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
|
||||
fs::create_dir_all(p.join("data")).unwrap();
|
||||
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
|
||||
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
|
||||
fs::write(
|
||||
p.join(HISTORY_LOG),
|
||||
"T|ok|create table Customers with pk id(serial)\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
|
||||
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
|
||||
// Stray atomic-write staging file — must be excluded.
|
||||
@@ -536,7 +525,9 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
|
||||
p.join(crate::undo::SNAPSHOTS_DIR)
|
||||
.join("0")
|
||||
.join(PLAYGROUND_DB),
|
||||
[0u8; 16],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -618,11 +609,15 @@ mod tests {
|
||||
let zip_path = tmp.path().join("notaproject.zip");
|
||||
let f = fs::File::create(&zip_path).unwrap();
|
||||
let mut w = ZipWriter::new(f);
|
||||
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
|
||||
w.start_file("foo/bar.txt", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
w.write_all(b"hi").unwrap();
|
||||
w.finish().unwrap();
|
||||
let err = inspect_zip(&zip_path).expect_err("must refuse");
|
||||
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(err, ArchiveError::NotAProjectArchive),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -631,13 +626,18 @@ mod tests {
|
||||
let zip_path = tmp.path().join("multi.zip");
|
||||
let f = fs::File::create(&zip_path).unwrap();
|
||||
let mut w = ZipWriter::new(f);
|
||||
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
|
||||
w.start_file("a/project.yaml", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
w.write_all(b"x").unwrap();
|
||||
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
|
||||
w.start_file("b/project.yaml", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
w.write_all(b"x").unwrap();
|
||||
w.finish().unwrap();
|
||||
let err = inspect_zip(&zip_path).expect_err("must refuse");
|
||||
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(err, ArchiveError::MultipleTopFolders),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+93
-24
@@ -30,6 +30,10 @@ pub struct Args {
|
||||
/// `--help` / `-h`: print usage to stdout and exit. The
|
||||
/// runtime checks this flag before doing any other work.
|
||||
pub help: bool,
|
||||
/// `--version` / `-V`: print the version (`CARGO_PKG_VERSION`,
|
||||
/// the single source of truth — ADR-0054) and exit. Checked
|
||||
/// alongside `--help` before any other work.
|
||||
pub version: bool,
|
||||
/// `--no-undo`: disable the auto-snapshot / undo machinery for
|
||||
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
|
||||
/// taken — zero per-command overhead — and `undo` / `redo`
|
||||
@@ -62,6 +66,17 @@ pub fn help_text() -> String {
|
||||
crate::t!("help.cli_banner")
|
||||
}
|
||||
|
||||
/// Version line printed by `--version` / `-V` and the in-app `version`
|
||||
/// command (ADR-0054).
|
||||
///
|
||||
/// `CARGO_PKG_VERSION` is the single source of truth — it equals the `v*`
|
||||
/// release tag (the release CI guards that), so what the binary reports
|
||||
/// always matches the downloaded artifact.
|
||||
#[must_use]
|
||||
pub fn version_text() -> String {
|
||||
crate::t!("cli.version_line", version = env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ArgsError {
|
||||
MissingValue(&'static str),
|
||||
@@ -81,10 +96,7 @@ pub enum ArgsError {
|
||||
impl std::fmt::Display for ArgsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingValue(flag) => f.write_str(&crate::t!(
|
||||
"cli.missing_value",
|
||||
flag = flag,
|
||||
)),
|
||||
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
|
||||
Self::InvalidValue {
|
||||
flag,
|
||||
value,
|
||||
@@ -95,10 +107,7 @@ impl std::fmt::Display for ArgsError {
|
||||
value = value,
|
||||
expected = expected,
|
||||
)),
|
||||
Self::Unknown(arg) => f.write_str(&crate::t!(
|
||||
"cli.unknown_argument",
|
||||
arg = arg,
|
||||
)),
|
||||
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
|
||||
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
|
||||
"cli.multiple_paths",
|
||||
first = first,
|
||||
@@ -129,6 +138,7 @@ impl Args {
|
||||
let mut project_path: Option<PathBuf> = None;
|
||||
let mut resume = false;
|
||||
let mut help = false;
|
||||
let mut version = false;
|
||||
let mut no_undo = false;
|
||||
let mut mode: Option<Mode> = None;
|
||||
// Demonstration mode (ADR-0047): the env var is the default,
|
||||
@@ -143,6 +153,9 @@ impl Args {
|
||||
"--help" | "-h" => {
|
||||
help = true;
|
||||
}
|
||||
"--version" | "-V" => {
|
||||
version = true;
|
||||
}
|
||||
"--resume" => {
|
||||
resume = true;
|
||||
}
|
||||
@@ -208,6 +221,7 @@ impl Args {
|
||||
project_path,
|
||||
resume,
|
||||
help,
|
||||
version,
|
||||
no_undo,
|
||||
mode,
|
||||
demo,
|
||||
@@ -241,7 +255,11 @@ fn default_theme() -> Theme {
|
||||
// Standard convention: 0..=6 and 8 are dark backgrounds,
|
||||
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
|
||||
let is_dark = matches!(code, 0..=6 | 8);
|
||||
return if is_dark { Theme::dark() } else { Theme::light() };
|
||||
return if is_dark {
|
||||
Theme::dark()
|
||||
} else {
|
||||
Theme::light()
|
||||
};
|
||||
}
|
||||
Theme::default()
|
||||
}
|
||||
@@ -294,10 +312,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mode_flag_simple_and_advanced() {
|
||||
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
|
||||
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
|
||||
assert_eq!(
|
||||
Args::parse(["--mode", "simple"]).unwrap().mode,
|
||||
Some(Mode::Simple)
|
||||
);
|
||||
assert_eq!(
|
||||
Args::parse(["--mode", "advanced"]).unwrap().mode,
|
||||
Some(Mode::Advanced)
|
||||
);
|
||||
// Case-insensitive, like the `mode` command.
|
||||
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
|
||||
assert_eq!(
|
||||
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
|
||||
Some(Mode::Advanced)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -330,7 +357,10 @@ mod tests {
|
||||
#[test]
|
||||
fn data_dir_flag_parses() {
|
||||
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
|
||||
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
|
||||
assert_eq!(
|
||||
args.data_dir.as_deref(),
|
||||
Some(std::path::Path::new("/tmp/playground-data"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -350,13 +380,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn data_dir_and_positional_can_coexist() {
|
||||
let args = Args::parse([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"/home/me/MyProject",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
|
||||
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
|
||||
assert_eq!(
|
||||
args.data_dir.as_deref(),
|
||||
Some(std::path::Path::new("/tmp/data"))
|
||||
);
|
||||
assert_eq!(
|
||||
args.project_path.as_deref(),
|
||||
Some(std::path::Path::new("/home/me/MyProject"))
|
||||
@@ -366,7 +394,10 @@ mod tests {
|
||||
#[test]
|
||||
fn two_positional_paths_error() {
|
||||
let err = Args::parse(["/a", "/b"]).unwrap_err();
|
||||
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(err, ArgsError::MultiplePaths { .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -435,7 +466,10 @@ mod tests {
|
||||
// Absent `--demo` (and absent env var in the test runner),
|
||||
// demo mode is off — zero footprint for real users.
|
||||
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
|
||||
assert!(!args.demo, "demo is off unless --demo or the env var is given");
|
||||
assert!(
|
||||
!args.demo,
|
||||
"demo is off unless --demo or the env var is given"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -464,7 +498,10 @@ mod tests {
|
||||
}
|
||||
// Disabling values.
|
||||
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
|
||||
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
|
||||
assert!(
|
||||
!demo_value_is_truthy(v),
|
||||
"{v:?} should not enable demo mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +510,38 @@ mod tests {
|
||||
// Make sure the path-vs-flag distinction is robust:
|
||||
// unknown flags don't get silently swallowed as paths.
|
||||
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
|
||||
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
|
||||
assert!(
|
||||
matches!(&err, ArgsError::Unknown(s) if s == "--bogus"),
|
||||
"got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ADR-0054: --version / -V ----
|
||||
|
||||
#[test]
|
||||
fn version_long_flag_parses() {
|
||||
assert!(Args::parse(["--version"]).unwrap().version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_short_flag_parses() {
|
||||
assert!(Args::parse(["-V"]).unwrap().version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_defaults_off() {
|
||||
assert!(!Args::parse(std::iter::empty::<&str>()).unwrap().version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_text_carries_the_cargo_version() {
|
||||
// The binary's self-reported version IS Cargo.toml's (the
|
||||
// single source of truth, ADR-0054) — and the release CI guards
|
||||
// that the `v*` tag equals it.
|
||||
let text = version_text();
|
||||
assert!(
|
||||
text.contains(env!("CARGO_PKG_VERSION")),
|
||||
"version line should embed CARGO_PKG_VERSION; got {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+202
-158
@@ -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,14 +473,11 @@ 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 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)
|
||||
}) {
|
||||
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
|
||||
out.push(col.clone());
|
||||
}
|
||||
}
|
||||
@@ -507,9 +504,7 @@ 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| {
|
||||
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.
|
||||
@@ -523,12 +518,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
{
|
||||
cols.iter().map(|c| c.name.clone()).collect()
|
||||
} else {
|
||||
resolve_qualifier_columns_in(
|
||||
q,
|
||||
resolution_from_scope,
|
||||
resolution_cte_bindings,
|
||||
cache,
|
||||
)
|
||||
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,8 +772,7 @@ 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() {
|
||||
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
|
||||
@@ -784,8 +790,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
} 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());
|
||||
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))
|
||||
{
|
||||
@@ -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,12 +1660,8 @@ 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,
|
||||
)
|
||||
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!(
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+21
-11
@@ -549,9 +549,16 @@ pub enum AppCommand {
|
||||
/// word like `insert` / `create` / `show`, or `types`), the
|
||||
/// focused detail for that command (or command group sharing
|
||||
/// the entry word).
|
||||
Help {
|
||||
topic: Option<String>,
|
||||
},
|
||||
Help { topic: Option<String> },
|
||||
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
|
||||
/// when submitted, it expands on the most recent runtime error
|
||||
/// (the buffer is empty post-submit). The live-input surface is
|
||||
/// the F1 keybinding, handled in `App::handle_key`, not here.
|
||||
Hint,
|
||||
/// Print the application version (ADR-0054): the in-app twin of the
|
||||
/// `--version` / `-V` CLI flag. Emits `CARGO_PKG_VERSION` — the same
|
||||
/// single source of truth — into the output panel.
|
||||
Version,
|
||||
/// Rebuild `playground.db` from `project.yaml` + data/, with
|
||||
/// confirmation modal.
|
||||
Rebuild,
|
||||
@@ -571,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.
|
||||
@@ -782,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,
|
||||
}
|
||||
}
|
||||
@@ -808,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,
|
||||
@@ -1013,6 +1023,8 @@ impl Command {
|
||||
Self::App(app) => match app {
|
||||
AppCommand::Quit => "quit",
|
||||
AppCommand::Help { .. } => "help",
|
||||
AppCommand::Hint => "hint",
|
||||
AppCommand::Version => "version",
|
||||
AppCommand::Rebuild => "rebuild",
|
||||
AppCommand::Save => "save",
|
||||
AppCommand::SaveAs => "save as",
|
||||
@@ -1145,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).
|
||||
|
||||
+67
-19
@@ -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};
|
||||
|
||||
@@ -69,10 +68,7 @@ const IMPORT_TARGET: Node = Node::Hinted {
|
||||
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]);
|
||||
@@ -174,9 +170,16 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
|
||||
Ok(Command::App(AppCommand::Rebuild))
|
||||
}
|
||||
|
||||
const fn build_version(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Version))
|
||||
}
|
||||
|
||||
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Undo))
|
||||
}
|
||||
const fn build_hint(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Hint))
|
||||
}
|
||||
|
||||
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Redo))
|
||||
@@ -263,88 +266,133 @@ pub static QUIT: CommandNode = CommandNode {
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_quit,
|
||||
help_id: Some("app.quit"),
|
||||
usage_ids: &["parse.usage.quit"],};
|
||||
hint_ids: &["quit"],
|
||||
usage_ids: &["parse.usage.quit"],
|
||||
};
|
||||
|
||||
pub static HELP: CommandNode = CommandNode {
|
||||
entry: Word::keyword("help"),
|
||||
shape: HELP_TOPIC_OPT,
|
||||
ast_builder: build_help,
|
||||
help_id: Some("app.help"),
|
||||
usage_ids: &["parse.usage.help"],};
|
||||
hint_ids: &["help"],
|
||||
usage_ids: &["parse.usage.help"],
|
||||
};
|
||||
|
||||
pub static HINT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("hint"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_hint,
|
||||
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"],
|
||||
};
|
||||
|
||||
pub static REBUILD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("rebuild"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_rebuild,
|
||||
help_id: Some("app.rebuild"),
|
||||
usage_ids: &["parse.usage.rebuild"],};
|
||||
hint_ids: &["rebuild"],
|
||||
usage_ids: &["parse.usage.rebuild"],
|
||||
};
|
||||
|
||||
pub static VERSION: CommandNode = CommandNode {
|
||||
entry: Word::keyword("version"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_version,
|
||||
help_id: Some("app.version"),
|
||||
hint_ids: &["version"],
|
||||
usage_ids: &["parse.usage.version"],
|
||||
};
|
||||
|
||||
pub static SAVE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("save"),
|
||||
shape: SAVE_AS_OPT,
|
||||
ast_builder: build_save,
|
||||
help_id: Some("app.save"),
|
||||
usage_ids: &["parse.usage.save"],};
|
||||
hint_ids: &["save"],
|
||||
usage_ids: &["parse.usage.save"],
|
||||
};
|
||||
|
||||
pub static NEW: CommandNode = CommandNode {
|
||||
entry: Word::keyword("new"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_new,
|
||||
help_id: Some("app.new"),
|
||||
usage_ids: &["parse.usage.new"],};
|
||||
hint_ids: &["new"],
|
||||
usage_ids: &["parse.usage.new"],
|
||||
};
|
||||
|
||||
pub static LOAD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("load"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_load,
|
||||
help_id: Some("app.load"),
|
||||
usage_ids: &["parse.usage.load"],};
|
||||
hint_ids: &["load"],
|
||||
usage_ids: &["parse.usage.load"],
|
||||
};
|
||||
|
||||
pub static EXPORT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("export"),
|
||||
shape: EXPORT_PATH_OPT,
|
||||
ast_builder: build_export,
|
||||
help_id: Some("app.export"),
|
||||
usage_ids: &["parse.usage.export"],};
|
||||
hint_ids: &["export"],
|
||||
usage_ids: &["parse.usage.export"],
|
||||
};
|
||||
|
||||
pub static IMPORT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("import"),
|
||||
shape: IMPORT_BODY_OPT,
|
||||
ast_builder: build_import,
|
||||
help_id: Some("app.import"),
|
||||
usage_ids: &["parse.usage.import"],};
|
||||
hint_ids: &["import"],
|
||||
usage_ids: &["parse.usage.import"],
|
||||
};
|
||||
|
||||
pub static MODE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("mode"),
|
||||
shape: MODE_VALUE,
|
||||
ast_builder: build_mode,
|
||||
help_id: Some("app.mode"),
|
||||
usage_ids: &["parse.usage.mode"],};
|
||||
hint_ids: &["mode"],
|
||||
usage_ids: &["parse.usage.mode"],
|
||||
};
|
||||
|
||||
pub static MESSAGES: CommandNode = CommandNode {
|
||||
entry: Word::keyword("messages"),
|
||||
shape: MESSAGES_VALUE_OPT,
|
||||
ast_builder: build_messages,
|
||||
help_id: Some("app.messages"),
|
||||
usage_ids: &["parse.usage.messages"],};
|
||||
hint_ids: &["messages"],
|
||||
usage_ids: &["parse.usage.messages"],
|
||||
};
|
||||
|
||||
pub static UNDO: CommandNode = CommandNode {
|
||||
entry: Word::keyword("undo"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_undo,
|
||||
help_id: Some("app.undo"),
|
||||
usage_ids: &["parse.usage.undo"],};
|
||||
hint_ids: &["undo"],
|
||||
usage_ids: &["parse.usage.undo"],
|
||||
};
|
||||
|
||||
pub static REDO: CommandNode = CommandNode {
|
||||
entry: Word::keyword("redo"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_redo,
|
||||
help_id: Some("app.redo"),
|
||||
usage_ids: &["parse.usage.redo"],};
|
||||
hint_ids: &["redo"],
|
||||
usage_ids: &["parse.usage.redo"],
|
||||
};
|
||||
|
||||
pub static COPY: CommandNode = CommandNode {
|
||||
entry: Word::keyword("copy"),
|
||||
shape: COPY_VALUE_OPT,
|
||||
ast_builder: build_copy,
|
||||
help_id: Some("app.copy"),
|
||||
usage_ids: &["parse.usage.copy"],};
|
||||
hint_ids: &["copy"],
|
||||
usage_ids: &["parse.usage.copy"],
|
||||
};
|
||||
|
||||
+114
-60
@@ -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};
|
||||
|
||||
// =================================================================
|
||||
@@ -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] = &[
|
||||
@@ -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] = &[
|
||||
@@ -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,
|
||||
@@ -438,6 +430,17 @@ const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
|
||||
const SEED_COUNT: Node = Node::NumberLit {
|
||||
validator: Some(LIMIT_VALIDATOR),
|
||||
};
|
||||
/// Issue #26: the row count is a bare positional number, so it produces
|
||||
/// no Tab candidate and was invisible in the hint panel at
|
||||
/// `seed <table> ▮` (only `set` / `--seed` showed). Wrapping it in
|
||||
/// `IntroProse` advertises it (and the other options) in prose; the
|
||||
/// skipped-optional carry (`surviving_intro_hint`) makes the hint reach
|
||||
/// the resolver despite the trailing optionals. Tab still cycles the
|
||||
/// keyword candidates.
|
||||
const SEED_COUNT_HINTED: Node = Node::Hinted {
|
||||
mode: crate::dsl::grammar::HintMode::IntroProse("hint.seed_count"),
|
||||
inner: &SEED_COUNT,
|
||||
};
|
||||
/// `--seed <n>` — a reproducible-generation flag carrying a numeric
|
||||
/// seed (ADR-0048 D4). The only flag in the DSL that takes a value;
|
||||
/// `build_seed` reads the number immediately after the flag.
|
||||
@@ -557,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] = &[
|
||||
@@ -567,7 +569,7 @@ const SEED_NODES: &[Node] = &[
|
||||
// against this table.
|
||||
TABLE_NAME_WRITES,
|
||||
SEED_DOT_COLUMN,
|
||||
Node::Optional(&SEED_COUNT),
|
||||
Node::Optional(&SEED_COUNT_HINTED),
|
||||
Node::Optional(&SEED_SET_CLAUSE),
|
||||
Node::Optional(&SEED_FLAG),
|
||||
];
|
||||
@@ -969,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();
|
||||
@@ -990,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)?;
|
||||
@@ -1000,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()))
|
||||
@@ -1074,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())],
|
||||
@@ -1130,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
|
||||
@@ -1236,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() {
|
||||
@@ -1484,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]
|
||||
@@ -1779,6 +1802,13 @@ pub static SHOW: CommandNode = CommandNode {
|
||||
shape: SHOW_SHAPE,
|
||||
ast_builder: build_show,
|
||||
help_id: Some("data.show"),
|
||||
hint_ids: &[
|
||||
"show_data",
|
||||
"show_table",
|
||||
"show_tables",
|
||||
"show_relationships",
|
||||
"show_indexes",
|
||||
],
|
||||
usage_ids: &[
|
||||
"parse.usage.show_data",
|
||||
"parse.usage.show_table",
|
||||
@@ -1787,13 +1817,15 @@ 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"),
|
||||
shape: SEED_SHAPE,
|
||||
ast_builder: build_seed,
|
||||
help_id: Some("data.seed"),
|
||||
hint_ids: &["seed"],
|
||||
usage_ids: &["parse.usage.seed"],
|
||||
};
|
||||
|
||||
@@ -1802,35 +1834,46 @@ pub static INSERT: CommandNode = CommandNode {
|
||||
shape: INSERT_SHAPE,
|
||||
ast_builder: build_insert,
|
||||
help_id: Some("data.insert"),
|
||||
usage_ids: &["parse.usage.insert"],};
|
||||
// ADR-0053 Phase-B exemplar.
|
||||
hint_ids: &["insert"],
|
||||
usage_ids: &["parse.usage.insert"],
|
||||
};
|
||||
|
||||
pub static UPDATE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("update"),
|
||||
shape: UPDATE_SHAPE,
|
||||
ast_builder: build_update,
|
||||
help_id: Some("data.update"),
|
||||
usage_ids: &["parse.usage.update"],};
|
||||
hint_ids: &["update"],
|
||||
usage_ids: &["parse.usage.update"],
|
||||
};
|
||||
|
||||
pub static DELETE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("delete"),
|
||||
shape: DELETE_SHAPE,
|
||||
ast_builder: build_delete,
|
||||
help_id: Some("data.delete"),
|
||||
usage_ids: &["parse.usage.delete"],};
|
||||
hint_ids: &["delete"],
|
||||
usage_ids: &["parse.usage.delete"],
|
||||
};
|
||||
|
||||
pub static REPLAY: CommandNode = CommandNode {
|
||||
entry: Word::keyword("replay"),
|
||||
shape: REPLAY_PATH,
|
||||
ast_builder: build_replay,
|
||||
help_id: Some("data.replay"),
|
||||
usage_ids: &["parse.usage.replay"],};
|
||||
hint_ids: &["replay"],
|
||||
usage_ids: &["parse.usage.replay"],
|
||||
};
|
||||
|
||||
pub static EXPLAIN: CommandNode = CommandNode {
|
||||
entry: Word::keyword("explain"),
|
||||
shape: EXPLAIN_SHAPE,
|
||||
ast_builder: build_explain,
|
||||
help_id: Some("data.explain"),
|
||||
usage_ids: &["parse.usage.explain"],};
|
||||
hint_ids: &["explain"],
|
||||
usage_ids: &["parse.usage.explain"],
|
||||
};
|
||||
|
||||
/// `explain` over advanced-mode SQL (ADR-0039).
|
||||
///
|
||||
@@ -1849,7 +1892,9 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
||||
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
||||
// precedent; otherwise `note_help` would print `explain` twice.
|
||||
help_id: None,
|
||||
usage_ids: &[],};
|
||||
hint_ids: &["explain_sql"],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
||||
///
|
||||
@@ -1864,7 +1909,9 @@ pub static SELECT: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
usage_ids: &["parse.usage.select"],};
|
||||
hint_ids: &["select"],
|
||||
usage_ids: &["parse.usage.select"],
|
||||
};
|
||||
|
||||
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
||||
///
|
||||
@@ -1878,7 +1925,9 @@ pub static WITH: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
usage_ids: &["parse.usage.with"],};
|
||||
hint_ids: &["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).
|
||||
@@ -1895,6 +1944,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||
ast_builder: build_sql_insert,
|
||||
help_id: None,
|
||||
hint_ids: &["sql_insert"],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1908,6 +1958,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||
ast_builder: build_sql_update,
|
||||
help_id: None,
|
||||
hint_ids: &["sql_update"],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1923,6 +1974,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||
ast_builder: build_sql_delete,
|
||||
help_id: None,
|
||||
hint_ids: &["sql_delete"],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1962,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));
|
||||
@@ -2021,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:?}"),
|
||||
}
|
||||
@@ -2054,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')");
|
||||
}
|
||||
@@ -2065,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");
|
||||
}
|
||||
@@ -2076,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");
|
||||
}
|
||||
@@ -2117,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]
|
||||
@@ -2134,8 +2190,7 @@ 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)
|
||||
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
|
||||
.expect("explain offers candidates");
|
||||
let names: Vec<&str> = completion
|
||||
.candidates
|
||||
@@ -2147,4 +2202,3 @@ mod explain_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+206
-100
@@ -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
|
||||
@@ -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")),
|
||||
@@ -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] = &[
|
||||
@@ -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 {
|
||||
@@ -968,39 +958,62 @@ pub static DROP: CommandNode = CommandNode {
|
||||
shape: DROP_SHAPE,
|
||||
ast_builder: build_drop,
|
||||
help_id: Some("ddl.drop"),
|
||||
hint_ids: &[
|
||||
"drop_table",
|
||||
"drop_column",
|
||||
"drop_relationship",
|
||||
"drop_index",
|
||||
"drop_constraint",
|
||||
],
|
||||
usage_ids: &[
|
||||
"parse.usage.drop_table",
|
||||
"parse.usage.drop_column",
|
||||
"parse.usage.drop_relationship",
|
||||
"parse.usage.drop_index",
|
||||
"parse.usage.drop_constraint",
|
||||
],};
|
||||
],
|
||||
};
|
||||
|
||||
pub static ADD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("add"),
|
||||
shape: ADD_SHAPE,
|
||||
ast_builder: build_add,
|
||||
help_id: Some("ddl.add"),
|
||||
// Per-form (ADR-0053 D3): every form is listed so the form-word
|
||||
// disambiguation resolves correctly; forms without an authored
|
||||
// block yet fall back to tier-2 at render. `add_relationship` is
|
||||
// authored as a Phase-B exemplar.
|
||||
hint_ids: &[
|
||||
"add_column",
|
||||
"add_relationship",
|
||||
"add_index",
|
||||
"add_constraint",
|
||||
],
|
||||
usage_ids: &[
|
||||
"parse.usage.add_column",
|
||||
"parse.usage.add_relationship",
|
||||
"parse.usage.add_index",
|
||||
"parse.usage.add_constraint",
|
||||
],};
|
||||
],
|
||||
};
|
||||
|
||||
pub static RENAME: CommandNode = CommandNode {
|
||||
entry: Word::keyword("rename"),
|
||||
shape: RENAME_COLUMN,
|
||||
ast_builder: build_rename_column,
|
||||
help_id: Some("ddl.rename"),
|
||||
usage_ids: &["parse.usage.rename_column"],};
|
||||
hint_ids: &["rename_column"],
|
||||
usage_ids: &["parse.usage.rename_column"],
|
||||
};
|
||||
|
||||
pub static CHANGE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("change"),
|
||||
shape: CHANGE_COLUMN,
|
||||
ast_builder: build_change_column,
|
||||
help_id: Some("ddl.change"),
|
||||
usage_ids: &["parse.usage.change_column"],};
|
||||
hint_ids: &["change_column"],
|
||||
usage_ids: &["parse.usage.change_column"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
|
||||
@@ -1055,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
|
||||
@@ -1095,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] = &[
|
||||
@@ -1256,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())],
|
||||
@@ -1360,7 +1380,9 @@ pub static CREATE: CommandNode = CommandNode {
|
||||
shape: CREATE_TABLE,
|
||||
ast_builder: build_create_table,
|
||||
help_id: Some("ddl.create"),
|
||||
usage_ids: &["parse.usage.create_table"],};
|
||||
hint_ids: &["create_table"],
|
||||
usage_ids: &["parse.usage.create_table"],
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
|
||||
@@ -1428,6 +1450,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
|
||||
shape: CREATE_M2N_SHAPE,
|
||||
ast_builder: build_create_m2n,
|
||||
help_id: Some("ddl.create_m2n"),
|
||||
hint_ids: &["create_m2n"],
|
||||
usage_ids: &["parse.usage.create_m2n"],
|
||||
};
|
||||
|
||||
@@ -1485,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())],
|
||||
@@ -1512,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;
|
||||
@@ -1536,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();
|
||||
}
|
||||
@@ -1554,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 => {}
|
||||
@@ -1567,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());
|
||||
}
|
||||
@@ -1626,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
|
||||
@@ -1653,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 =
|
||||
@@ -1838,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
|
||||
@@ -1858,6 +1911,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
|
||||
ast_builder: build_sql_create_table,
|
||||
help_id: Some("ddl.sql_create_table"),
|
||||
hint_ids: &["sql_create_table"],
|
||||
usage_ids: &["parse.usage.sql_create_table"],
|
||||
};
|
||||
|
||||
@@ -1877,6 +1931,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
||||
shape: SQL_DROP_TABLE_SHAPE,
|
||||
ast_builder: build_sql_drop_table,
|
||||
help_id: Some("ddl.sql_drop_table"),
|
||||
hint_ids: &["sql_drop_table"],
|
||||
usage_ids: &["parse.usage.sql_drop_table"],
|
||||
};
|
||||
|
||||
@@ -1896,6 +1951,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
||||
shape: SQL_DROP_INDEX_SHAPE,
|
||||
ast_builder: build_sql_drop_index,
|
||||
help_id: Some("ddl.sql_drop_index"),
|
||||
hint_ids: &["sql_drop_index"],
|
||||
usage_ids: &["parse.usage.sql_drop_index"],
|
||||
};
|
||||
|
||||
@@ -1909,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] = &[
|
||||
@@ -1977,6 +2034,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||||
shape: SQL_CREATE_INDEX_SHAPE,
|
||||
ast_builder: build_sql_create_index,
|
||||
help_id: Some("ddl.sql_create_index"),
|
||||
hint_ids: &["sql_create_index"],
|
||||
usage_ids: &["parse.usage.sql_create_index"],
|
||||
};
|
||||
|
||||
@@ -2079,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);
|
||||
@@ -2107,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")),
|
||||
@@ -2124,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);
|
||||
@@ -2233,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())],
|
||||
@@ -2255,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;
|
||||
@@ -2301,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 {
|
||||
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!(
|
||||
@@ -2354,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 },
|
||||
@@ -2470,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"))
|
||||
@@ -2503,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('('))) {
|
||||
@@ -2523,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.
|
||||
@@ -2535,6 +2610,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||||
shape: SQL_ALTER_TABLE_SHAPE,
|
||||
ast_builder: build_sql_alter_table,
|
||||
help_id: Some("ddl.sql_alter_table"),
|
||||
hint_ids: &["sql_alter_table"],
|
||||
usage_ids: &["parse.usage.sql_alter_table"],
|
||||
};
|
||||
|
||||
@@ -2600,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]
|
||||
@@ -2625,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);
|
||||
}
|
||||
@@ -2656,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());
|
||||
}
|
||||
|
||||
@@ -2705,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, ..
|
||||
} => {
|
||||
@@ -2800,7 +2878,10 @@ mod sql_drop_table_tests {
|
||||
Command::DropColumn { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
|
||||
parse_command_in_mode(
|
||||
"drop relationship Customers_id_to_Orders_CustId",
|
||||
Mode::Advanced
|
||||
)
|
||||
.expect("parses"),
|
||||
Command::DropRelationship { .. }
|
||||
));
|
||||
@@ -2906,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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -3108,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]
|
||||
@@ -3187,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,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -3212,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");
|
||||
}
|
||||
@@ -3291,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:?}"),
|
||||
}
|
||||
@@ -3309,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:?}"),
|
||||
}
|
||||
@@ -3326,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}"
|
||||
);
|
||||
}
|
||||
@@ -3338,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}"
|
||||
);
|
||||
}
|
||||
@@ -3366,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:?}"),
|
||||
}
|
||||
|
||||
+21
-32
@@ -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"),
|
||||
|
||||
+300
-56
@@ -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
|
||||
@@ -530,6 +528,18 @@ pub struct CommandNode {
|
||||
/// so a newly-registered command appears in `help`
|
||||
/// automatically (ADR-0024 §help_id).
|
||||
pub help_id: Option<&'static str>,
|
||||
/// Catalog key stems (`hint.cmd.<id>`) for this command's
|
||||
/// **tier-3** contextual hints (ADR-0053 / H2), **one per form**,
|
||||
/// mirroring `usage_ids`. A single-form command carries one; a
|
||||
/// multi-form command (`add`, `drop`, `show`, `create`) carries
|
||||
/// one per form so a live-input hint can be specific to the form
|
||||
/// being typed (`hint.cmd.add_relationship`, not a shared `add`
|
||||
/// block). `hint_key_for_input_in_mode` disambiguates by the form
|
||||
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
|
||||
/// until a form's tier-3 block is authored (the surface falls back
|
||||
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
|
||||
/// `None` on advanced-SQL forms purely to dedup the `help` list).
|
||||
pub hint_ids: &'static [&'static str],
|
||||
/// Catalog keys under `parse.usage.*` to render in the
|
||||
/// "usage:" block when a parse error fires for this command
|
||||
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
|
||||
@@ -574,32 +584,100 @@ pub fn usage_keys_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Option<(&'static str, Vec<&'static str>)> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let start = skip_whitespace(source, 0);
|
||||
let (kw_start, kw_end) = consume_ident(source, start)?;
|
||||
let word = &source[kw_start..kw_end];
|
||||
let candidates = commands_for_entry_word(word);
|
||||
if candidates.is_empty() {
|
||||
let pick = selected_nodes_for_input_in_mode(source, mode);
|
||||
if pick.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> {
|
||||
let mut keys: Vec<&'static str> = Vec::new();
|
||||
for (_, node, _) in nodes {
|
||||
for (_, node, _) in &pick {
|
||||
for k in node.usage_ids {
|
||||
if !keys.contains(k) {
|
||||
keys.push(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entry = pick[0].1.entry.primary;
|
||||
Some((entry, keys))
|
||||
}
|
||||
|
||||
/// The single tier-3 hint key (`hint.cmd.<id>` stem) for the command
|
||||
/// **form** `source` is currently typing, in `mode` (H2 / ADR-0053).
|
||||
///
|
||||
/// Mirrors [`usage_key_for_input_in_mode`]: the union of the
|
||||
/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by
|
||||
/// [`pick_form_key`] — so `add 1:n relationship` resolves to the
|
||||
/// relationship hint, and an advanced-SQL form resolves to its own
|
||||
/// (not its simple sibling's). `None` if no entry word matches or the
|
||||
/// form has no tier-3 block yet (the caller falls back to tier-2).
|
||||
#[must_use]
|
||||
pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let nodes = selected_nodes_for_input_in_mode(source, mode);
|
||||
if nodes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Mode-ordered union (advanced-primary first in advanced mode), so a
|
||||
// shared entry word resolves to the surface the user is in.
|
||||
let mut keys: Vec<&'static str> = Vec::new();
|
||||
for (_, node, _) in &nodes {
|
||||
for k in node.hint_ids {
|
||||
if !keys.contains(k) {
|
||||
keys.push(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if keys.len() == 1 {
|
||||
return Some(keys[0]);
|
||||
}
|
||||
// A bare multi-form entry word (no form word yet — `add`⏎) has no
|
||||
// chosen form: defer to tier-2, which lists the choices.
|
||||
let start = skip_whitespace(source, 0);
|
||||
if let Some((_, entry_end)) = consume_ident(source, start)
|
||||
&& skip_whitespace(source, entry_end) >= source.len()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A form word picks the form (`drop column` → `drop_column`); when
|
||||
// the second token isn't a form word (`insert into …`, `update …
|
||||
// set`), fall back to the mode-primary key — in advanced mode the
|
||||
// SQL form, in simple mode the DSL form.
|
||||
pick_form_key(source, &keys).or_else(|| keys.first().copied())
|
||||
}
|
||||
|
||||
/// Shared mode-aware command-form selection for the entry word at the
|
||||
/// start of `source`.
|
||||
///
|
||||
/// Extracted so the usage-key and hint-id lookups agree on which form
|
||||
/// the user is typing.
|
||||
///
|
||||
/// Advanced mode: every candidate form is reachable — the SQL nodes
|
||||
/// are primary, and the DSL nodes remain valid via fallback (verified:
|
||||
/// `create table … with pk` and `drop column …` both run in advanced
|
||||
/// mode). Mode-primary (Advanced) first, so a hint never hides input
|
||||
/// that works. Simple mode: only the DSL forms — the SQL-only forms
|
||||
/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.)
|
||||
/// Degenerate guard: an advanced-only word in simple mode leaves the
|
||||
/// selection empty; fall back to all candidates.
|
||||
fn selected_nodes_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let start = skip_whitespace(source, 0);
|
||||
let Some((kw_start, kw_end)) = consume_ident(source, start) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Advanced mode: every candidate form is reachable — the SQL
|
||||
// nodes are primary, and the DSL nodes remain valid via fallback
|
||||
// (verified: `create table … with pk` and `drop column …` both
|
||||
// run in advanced mode). Show them all, mode-primary (Advanced)
|
||||
// first, so the usage hint never hides input that works. Simple
|
||||
// mode: only the DSL forms — the SQL-only forms hit the "this is
|
||||
// SQL" rail and are not reachable. (ADR-0042 G3.)
|
||||
let word = &source[kw_start..kw_end];
|
||||
let candidates = commands_for_entry_word(word);
|
||||
if candidates.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
|
||||
if mode == crate::mode::Mode::Advanced {
|
||||
let mut v: Vec<_> = candidates
|
||||
@@ -621,17 +699,11 @@ pub fn usage_keys_for_input_in_mode(
|
||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||
.collect()
|
||||
};
|
||||
// Degenerate guard: an advanced-only word in simple mode (not
|
||||
// normally reachable — it hits the SQL rail first) leaves
|
||||
// `selected` empty; fall back to all candidates so a usage block
|
||||
// still renders rather than the available-commands fallback.
|
||||
let pick = if selected.is_empty() { candidates } else { selected };
|
||||
let keys = union(&pick);
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
if selected.is_empty() {
|
||||
candidates
|
||||
} else {
|
||||
selected
|
||||
}
|
||||
let entry = pick[0].1.entry.primary;
|
||||
Some((entry, keys))
|
||||
}
|
||||
|
||||
/// The single usage template most relevant to `source`, when
|
||||
@@ -654,18 +726,25 @@ 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> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
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)
|
||||
}
|
||||
|
||||
/// From the form word after the entry keyword, pick the single `keys`
|
||||
/// entry for the form `source` names.
|
||||
///
|
||||
/// A single-entry list resolves to its one key; a multi-form list
|
||||
/// disambiguates by the form word (`add 1:n relationship` → the
|
||||
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
|
||||
/// identifier form word matched against each key's suffix). Shared by
|
||||
/// the usage-template and tier-3-hint single-key lookups so they agree.
|
||||
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let first = *keys.first()?;
|
||||
if keys.len() == 1 {
|
||||
return Some(first);
|
||||
}
|
||||
// Multi-form: the form is named by the token right after
|
||||
// the entry keyword.
|
||||
let start = skip_whitespace(source, 0);
|
||||
let (_, entry_end) = consume_ident(source, start)?;
|
||||
let after = skip_whitespace(source, entry_end);
|
||||
@@ -674,14 +753,15 @@ pub fn usage_key_for_input_in_mode(
|
||||
return keys.iter().copied().find(|k| k.ends_with("relationship"));
|
||||
}
|
||||
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||
// — a letter, so the digit branch misses it, and its usage key ends
|
||||
// `…create_m2n` (not `relationship`).
|
||||
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("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"))
|
||||
{
|
||||
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||
}
|
||||
// Otherwise the form word is an identifier — `column`,
|
||||
// `index`, `table`, `relationship` — matched against the
|
||||
// usage key's suffix.
|
||||
// Otherwise the form word is an identifier — `column`, `index`,
|
||||
// `table`, `relationship` — matched against each key's suffix.
|
||||
let (s, e) = consume_ident(source, after)?;
|
||||
let form = source[s..e].to_ascii_lowercase();
|
||||
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
|
||||
@@ -692,8 +772,7 @@ pub fn usage_key_for_input_in_mode(
|
||||
/// 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
|
||||
@@ -712,6 +791,8 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> {
|
||||
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&app::QUIT, CommandCategory::Simple),
|
||||
(&app::HELP, CommandCategory::Simple),
|
||||
(&app::HINT, CommandCategory::Simple),
|
||||
(&app::VERSION, CommandCategory::Simple),
|
||||
(&app::REBUILD, CommandCategory::Simple),
|
||||
(&app::SAVE, CommandCategory::Simple),
|
||||
(&app::NEW, CommandCategory::Simple),
|
||||
@@ -825,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()
|
||||
@@ -836,6 +915,177 @@ pub fn commands_for_entry_word(
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hint_key_tests {
|
||||
use super::hint_key_for_input_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
/// Per-form hint keying (ADR-0053 D3): a multi-form command
|
||||
/// resolves the *typed* form, not the node — `add 1:n
|
||||
/// relationship` → the relationship hint, `add column` → the
|
||||
/// (as-yet-unauthored) column hint, never the wrong form.
|
||||
#[test]
|
||||
fn hint_key_resolves_the_typed_form() {
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("add 1:n relationship from A.x to B.y", Mode::Simple),
|
||||
Some("add_relationship")
|
||||
);
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("add column Note text to T", Mode::Simple),
|
||||
Some("add_column")
|
||||
);
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple),
|
||||
Some("insert")
|
||||
);
|
||||
// Multi-form DROP disambiguates to the typed form too.
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("drop table T", Mode::Simple),
|
||||
Some("drop_table")
|
||||
);
|
||||
// Mode picks the surface for a shared entry word whose second
|
||||
// token isn't a form word: SQL form in advanced, DSL in simple.
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("insert into T values (1)", Mode::Advanced),
|
||||
Some("sql_insert")
|
||||
);
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple),
|
||||
Some("insert")
|
||||
);
|
||||
// `create table` shares a form word — advanced-first ordering
|
||||
// resolves it to the SQL form in advanced mode.
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("create table T (id int)", Mode::Advanced),
|
||||
Some("sql_create_table")
|
||||
);
|
||||
// Unknown entry word → None (tier-2 fallback).
|
||||
assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None);
|
||||
}
|
||||
|
||||
/// Comprehensiveness gate (ADR-0053 D6): every command form in the
|
||||
/// REGISTRY carries at least one `hint_id`, and each resolves to a
|
||||
/// tier-3 `hint.cmd.<id>` block. `keys.rs` checks referenced keys
|
||||
/// resolve; this checks every command *has* one.
|
||||
#[test]
|
||||
fn every_command_form_has_a_tier3_block() {
|
||||
let cat = crate::friendly::catalog();
|
||||
for (node, _category) in super::REGISTRY {
|
||||
assert!(
|
||||
!node.hint_ids.is_empty(),
|
||||
"command `{}` has no hint_ids (ADR-0053 D6)",
|
||||
node.entry.primary
|
||||
);
|
||||
for id in node.hint_ids {
|
||||
let key = format!("hint.cmd.{id}.what");
|
||||
assert!(
|
||||
cat.get(&key).is_some(),
|
||||
"missing tier-3 block `{key}` for command `{}`",
|
||||
node.entry.primary
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensiveness gate (ADR-0053 D6): every runtime error class
|
||||
/// `friendly::error_hint_class` can return resolves to a tier-3
|
||||
/// `hint.err.<class>` block. Keep this list in sync with
|
||||
/// `error_hint_class` (its own unit tests pin the outputs).
|
||||
/// Diagnostic classes are deferred (issue #38), so not checked here.
|
||||
#[test]
|
||||
fn every_runtime_error_class_has_a_tier3_block() {
|
||||
let cat = crate::friendly::catalog();
|
||||
let classes = [
|
||||
"unique",
|
||||
"foreign_key.child_side",
|
||||
"foreign_key.parent_side",
|
||||
"not_null",
|
||||
"check",
|
||||
"type_mismatch",
|
||||
"not_found",
|
||||
"already_exists",
|
||||
"generic",
|
||||
"invalid_value",
|
||||
];
|
||||
for c in classes {
|
||||
let key = format!("hint.err.{c}.what");
|
||||
assert!(
|
||||
cat.get(&key).is_some(),
|
||||
"missing tier-3 error block `{key}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic-verification guard (handoff-71): every `hint.cmd.<form>`
|
||||
/// **example** must parse in the mode the form is taught for. This
|
||||
/// backstops the bug class found in the H2 corpus pass — an example
|
||||
/// that drifts out of the real grammar (a typo, a removed clause, or
|
||||
/// an argument the command never accepted, e.g. an inline name on
|
||||
/// `save as` which opens a modal instead). It cannot police the
|
||||
/// *semantics* of an example that happens to parse (that is the
|
||||
/// manual pass), but it locks the syntactic floor so future edits
|
||||
/// can't ship an unparseable teaching line.
|
||||
///
|
||||
/// The mode per form mirrors `hint_key_for_input_in_mode`: the
|
||||
/// advanced-SQL forms are taught in advanced mode; everything else
|
||||
/// (DSL + app commands) in simple mode.
|
||||
#[test]
|
||||
fn every_cmd_hint_example_parses_in_its_mode() {
|
||||
use crate::dsl::parser::parse_command_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
// Advanced-mode forms — the SQL surface (ADR-0030–0039). Every
|
||||
// other form (DSL + app commands) is taught in simple mode. This
|
||||
// mirrors the mode split `hint_key_for_input_in_mode` resolves.
|
||||
const ADVANCED: &[&str] = &[
|
||||
"sql_create_table",
|
||||
"sql_alter_table",
|
||||
"sql_create_index",
|
||||
"sql_drop_index",
|
||||
"sql_drop_table",
|
||||
"sql_insert",
|
||||
"sql_update",
|
||||
"sql_delete",
|
||||
"select",
|
||||
"with",
|
||||
"explain_sql",
|
||||
];
|
||||
|
||||
// Iterate the *catalog* (the corpus is the source of truth), not the
|
||||
// REGISTRY: this reaches every `hint.cmd.<id>` block including any
|
||||
// not owned by a command node, so an orphaned or mis-keyed example
|
||||
// can't slip past the guard.
|
||||
let cat = crate::friendly::catalog();
|
||||
let mut checked = 0usize;
|
||||
for key in cat.keys() {
|
||||
let Some(id) = key
|
||||
.strip_prefix("hint.cmd.")
|
||||
.and_then(|rest| rest.strip_suffix(".example"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let example = cat.get(key).expect("key came from the catalog");
|
||||
let mode = if ADVANCED.contains(&id) {
|
||||
Mode::Advanced
|
||||
} else {
|
||||
Mode::Simple
|
||||
};
|
||||
assert!(
|
||||
parse_command_in_mode(example, mode).is_ok(),
|
||||
"hint.cmd.{id}.example does not parse in {mode:?} mode: {example:?}",
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
// Floor guard: the corpus had 49 command forms at the time of
|
||||
// writing (ADR-0053). If this drops, a block (and its example
|
||||
// coverage) silently vanished.
|
||||
assert!(
|
||||
checked >= 49,
|
||||
"expected at least 49 hint.cmd.* examples, checked {checked}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod usage_key_tests {
|
||||
use super::usage_key_for_input;
|
||||
@@ -850,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",
|
||||
@@ -870,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
|
||||
|
||||
@@ -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)],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
+15
-47
@@ -91,10 +91,7 @@ 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),
|
||||
@@ -471,10 +439,7 @@ 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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -241,10 +248,8 @@ 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
|
||||
@@ -287,11 +289,8 @@ 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)
|
||||
@@ -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),
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+33
-14
@@ -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>()
|
||||
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")));
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,17 @@ pub struct WalkContext<'a> {
|
||||
/// resolver reads this directly instead of inferring the
|
||||
/// slot kind from the shape of the expected set.
|
||||
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
||||
/// An `IntroProse` hint captured from an *optional* slot that
|
||||
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
||||
/// (cleared on the very next match — including the empty match
|
||||
/// of a skipped `Optional`), this survives the trailing
|
||||
/// optional siblings so the hint reaches the resolver for a
|
||||
/// position like `seed <table> ▮`, where the optional row
|
||||
/// count is otherwise invisible. Carries the catalog key and
|
||||
/// the byte position the optional was skipped at; the resolver
|
||||
/// uses it only when that position is the cursor (so it doesn't
|
||||
/// leak past a later-consumed clause).
|
||||
pub surviving_intro_hint: Option<(&'static str, usize)>,
|
||||
/// The columns the user explicitly listed in
|
||||
/// `insert into <T> (col1, col2, …) values (…)` (Form A),
|
||||
/// in declaration order.
|
||||
@@ -232,6 +243,7 @@ impl<'a> WalkContext<'a> {
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
from_scope_stack: vec![ScopeFrame::default()],
|
||||
@@ -254,6 +266,7 @@ impl<'a> WalkContext<'a> {
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
from_scope_stack: vec![ScopeFrame::default()],
|
||||
|
||||
+131
-174
@@ -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,8 +480,7 @@ fn walk_ident(
|
||||
columns: Vec::new(),
|
||||
});
|
||||
let placeholder_index = frame.cte_bindings.len() - 1;
|
||||
ctx.pending_cte_harvest =
|
||||
Some(crate::dsl::walker::context::PendingCteHarvest {
|
||||
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
|
||||
placeholder_index,
|
||||
col_list: Vec::new(),
|
||||
cte_name: text.clone(),
|
||||
@@ -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(
|
||||
@@ -990,6 +996,21 @@ fn walk_seq(
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #26: when an `Optional` is skipped (its inner didn't engage),
|
||||
/// stash any `IntroProse` hint the inner left in `pending_hint_mode`
|
||||
/// into the surviving slot before it is cleared by this empty match.
|
||||
/// `position` is where the optional was skipped — the resolver compares
|
||||
/// it to the cursor so the hint only shows while the cursor sits at that
|
||||
/// optional, not after a later clause consumes input past it. Only
|
||||
/// `IntroProse` is carried (it is the "introduce an optional position"
|
||||
/// mode); `ProseOnly` / `ForceProse` mark active slots and reach the
|
||||
/// resolver through the normal `pending_hint_mode` path.
|
||||
const fn capture_skipped_intro_hint(ctx: &mut WalkContext, position: usize) {
|
||||
if let Some(crate::dsl::grammar::HintMode::IntroProse(key)) = ctx.pending_hint_mode {
|
||||
ctx.surviving_intro_hint = Some((key, position));
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_optional(
|
||||
source: &str,
|
||||
position: usize,
|
||||
@@ -1008,6 +1029,7 @@ fn walk_optional(
|
||||
// Inner didn't engage at all — skip the Optional
|
||||
// but carry the inner's expectations so the caller's
|
||||
// expected-set sees them.
|
||||
capture_skipped_intro_hint(ctx, position);
|
||||
path.items.truncate(saved_path_len);
|
||||
per_byte.truncate(saved_byte_len);
|
||||
NodeWalkResult::Matched {
|
||||
@@ -1015,10 +1037,14 @@ 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.
|
||||
capture_skipped_intro_hint(ctx, position);
|
||||
path.items.truncate(saved_path_len);
|
||||
per_byte.truncate(saved_byte_len);
|
||||
let _ = p;
|
||||
@@ -1139,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);
|
||||
}
|
||||
|
||||
@@ -1223,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;
|
||||
@@ -1264,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
|
||||
@@ -1322,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;
|
||||
}
|
||||
@@ -1351,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);
|
||||
@@ -1366,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('*'))
|
||||
@@ -1396,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),
|
||||
@@ -1430,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),
|
||||
@@ -1476,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()));
|
||||
}
|
||||
@@ -1596,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;
|
||||
@@ -1612,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",
|
||||
@@ -1709,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) -----------------------
|
||||
@@ -1741,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",
|
||||
@@ -1784,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:?}",)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1805,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();
|
||||
@@ -1854,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");
|
||||
@@ -1864,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()));
|
||||
@@ -1876,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");
|
||||
@@ -1891,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");
|
||||
}
|
||||
@@ -1904,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");
|
||||
}
|
||||
@@ -1923,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();
|
||||
@@ -1951,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
|
||||
@@ -1967,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");
|
||||
@@ -1989,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()]
|
||||
@@ -2016,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());
|
||||
}
|
||||
|
||||
@@ -2071,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
|
||||
@@ -2091,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_,
|
||||
@@ -2144,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]
|
||||
@@ -2242,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]
|
||||
@@ -2270,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]
|
||||
@@ -2293,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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+436
-536
File diff suppressed because it is too large
Load Diff
+110
-38
@@ -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(),);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -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 {
|
||||
@@ -161,6 +166,11 @@ pub enum AppEvent {
|
||||
/// commands, so an execution failure would otherwise be
|
||||
/// lost across sessions.
|
||||
source: String,
|
||||
/// Whether the rejected command was submitted in an advanced
|
||||
/// effective mode (ADR-0052): threaded so the App can tag the
|
||||
/// `err` record `err:adv` and the failed advanced command
|
||||
/// hydrates in its `:`-prefixed, simple-mode-recallable form.
|
||||
advanced: bool,
|
||||
},
|
||||
/// Refreshed list of tables in the database.
|
||||
TablesRefreshed(Vec<String>),
|
||||
|
||||
+3
-11
@@ -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)
|
||||
}
|
||||
|
||||
+224
-34
@@ -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"],
|
||||
@@ -180,6 +186,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.unknown_topic", &["topic"]),
|
||||
("help.app.quit", &[]),
|
||||
("help.app.help", &[]),
|
||||
("help.app.hint", &[]),
|
||||
("help.app.version", &[]),
|
||||
("help.app.rebuild", &[]),
|
||||
("help.app.save", &[]),
|
||||
("help.app.new", &[]),
|
||||
@@ -217,25 +225,197 @@ 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.ambient_invalid_ident",
|
||||
&["kind", "found"],
|
||||
),
|
||||
("hint.getting_started", &[]),
|
||||
("hint.block.heading", &[]),
|
||||
("hint.block.what", &[]),
|
||||
("hint.block.example", &[]),
|
||||
("hint.block.concept", &[]),
|
||||
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
|
||||
("hint.cmd.insert.what", &[]),
|
||||
("hint.cmd.insert.example", &[]),
|
||||
("hint.cmd.insert.concept", &[]),
|
||||
("hint.cmd.add_relationship.what", &[]),
|
||||
("hint.cmd.add_relationship.example", &[]),
|
||||
("hint.cmd.add_relationship.concept", &[]),
|
||||
("hint.err.foreign_key.child_side.what", &[]),
|
||||
("hint.err.foreign_key.child_side.example", &[]),
|
||||
("hint.err.foreign_key.child_side.concept", &[]),
|
||||
// Phase C batch 5 — runtime error-class hints.
|
||||
("hint.err.foreign_key.parent_side.what", &[]),
|
||||
("hint.err.foreign_key.parent_side.example", &[]),
|
||||
("hint.err.foreign_key.parent_side.concept", &[]),
|
||||
("hint.err.unique.what", &[]),
|
||||
("hint.err.unique.example", &[]),
|
||||
("hint.err.unique.concept", &[]),
|
||||
("hint.err.not_null.what", &[]),
|
||||
("hint.err.not_null.example", &[]),
|
||||
("hint.err.not_null.concept", &[]),
|
||||
("hint.err.check.what", &[]),
|
||||
("hint.err.check.example", &[]),
|
||||
("hint.err.check.concept", &[]),
|
||||
("hint.err.type_mismatch.what", &[]),
|
||||
("hint.err.type_mismatch.example", &[]),
|
||||
("hint.err.type_mismatch.concept", &[]),
|
||||
("hint.err.not_found.what", &[]),
|
||||
("hint.err.not_found.example", &[]),
|
||||
("hint.err.not_found.concept", &[]),
|
||||
("hint.err.already_exists.what", &[]),
|
||||
("hint.err.already_exists.example", &[]),
|
||||
("hint.err.already_exists.concept", &[]),
|
||||
("hint.err.generic.what", &[]),
|
||||
("hint.err.generic.example", &[]),
|
||||
("hint.err.invalid_value.what", &[]),
|
||||
("hint.err.invalid_value.example", &[]),
|
||||
// Phase C batch 1 — app-lifecycle command hints.
|
||||
("hint.cmd.quit.what", &[]),
|
||||
("hint.cmd.quit.example", &[]),
|
||||
("hint.cmd.help.what", &[]),
|
||||
("hint.cmd.help.example", &[]),
|
||||
("hint.cmd.help.concept", &[]),
|
||||
("hint.cmd.hint.what", &[]),
|
||||
("hint.cmd.hint.example", &[]),
|
||||
("hint.cmd.version.what", &[]),
|
||||
("hint.cmd.version.example", &[]),
|
||||
("hint.cmd.rebuild.what", &[]),
|
||||
("hint.cmd.rebuild.example", &[]),
|
||||
("hint.cmd.rebuild.concept", &[]),
|
||||
("hint.cmd.save.what", &[]),
|
||||
("hint.cmd.save.example", &[]),
|
||||
("hint.cmd.save.concept", &[]),
|
||||
("hint.cmd.new.what", &[]),
|
||||
("hint.cmd.new.example", &[]),
|
||||
("hint.cmd.load.what", &[]),
|
||||
("hint.cmd.load.example", &[]),
|
||||
("hint.cmd.export.what", &[]),
|
||||
("hint.cmd.export.example", &[]),
|
||||
("hint.cmd.export.concept", &[]),
|
||||
("hint.cmd.import.what", &[]),
|
||||
("hint.cmd.import.example", &[]),
|
||||
("hint.cmd.mode.what", &[]),
|
||||
("hint.cmd.mode.example", &[]),
|
||||
("hint.cmd.mode.concept", &[]),
|
||||
("hint.cmd.messages.what", &[]),
|
||||
("hint.cmd.messages.example", &[]),
|
||||
("hint.cmd.messages.concept", &[]),
|
||||
("hint.cmd.undo.what", &[]),
|
||||
("hint.cmd.undo.example", &[]),
|
||||
("hint.cmd.undo.concept", &[]),
|
||||
("hint.cmd.redo.what", &[]),
|
||||
("hint.cmd.redo.example", &[]),
|
||||
("hint.cmd.copy.what", &[]),
|
||||
("hint.cmd.copy.example", &[]),
|
||||
// Phase C batch 2 — DDL command hints.
|
||||
("hint.cmd.create_table.what", &[]),
|
||||
("hint.cmd.create_table.example", &[]),
|
||||
("hint.cmd.create_table.concept", &[]),
|
||||
("hint.cmd.create_m2n.what", &[]),
|
||||
("hint.cmd.create_m2n.example", &[]),
|
||||
("hint.cmd.create_m2n.concept", &[]),
|
||||
("hint.cmd.add_column.what", &[]),
|
||||
("hint.cmd.add_column.example", &[]),
|
||||
("hint.cmd.add_column.concept", &[]),
|
||||
("hint.cmd.add_index.what", &[]),
|
||||
("hint.cmd.add_index.example", &[]),
|
||||
("hint.cmd.add_index.concept", &[]),
|
||||
("hint.cmd.add_constraint.what", &[]),
|
||||
("hint.cmd.add_constraint.example", &[]),
|
||||
("hint.cmd.add_constraint.concept", &[]),
|
||||
("hint.cmd.drop_table.what", &[]),
|
||||
("hint.cmd.drop_table.example", &[]),
|
||||
("hint.cmd.drop_table.concept", &[]),
|
||||
("hint.cmd.drop_column.what", &[]),
|
||||
("hint.cmd.drop_column.example", &[]),
|
||||
("hint.cmd.drop_column.concept", &[]),
|
||||
("hint.cmd.drop_relationship.what", &[]),
|
||||
("hint.cmd.drop_relationship.example", &[]),
|
||||
("hint.cmd.drop_relationship.concept", &[]),
|
||||
("hint.cmd.drop_index.what", &[]),
|
||||
("hint.cmd.drop_index.example", &[]),
|
||||
("hint.cmd.drop_index.concept", &[]),
|
||||
("hint.cmd.drop_constraint.what", &[]),
|
||||
("hint.cmd.drop_constraint.example", &[]),
|
||||
("hint.cmd.drop_constraint.concept", &[]),
|
||||
("hint.cmd.rename_column.what", &[]),
|
||||
("hint.cmd.rename_column.example", &[]),
|
||||
("hint.cmd.rename_column.concept", &[]),
|
||||
("hint.cmd.change_column.what", &[]),
|
||||
("hint.cmd.change_column.example", &[]),
|
||||
("hint.cmd.change_column.concept", &[]),
|
||||
// Phase C batch 3 — DML command hints.
|
||||
("hint.cmd.update.what", &[]),
|
||||
("hint.cmd.update.example", &[]),
|
||||
("hint.cmd.update.concept", &[]),
|
||||
("hint.cmd.delete.what", &[]),
|
||||
("hint.cmd.delete.example", &[]),
|
||||
("hint.cmd.delete.concept", &[]),
|
||||
("hint.cmd.show_data.what", &[]),
|
||||
("hint.cmd.show_data.example", &[]),
|
||||
("hint.cmd.show_data.concept", &[]),
|
||||
("hint.cmd.show_table.what", &[]),
|
||||
("hint.cmd.show_table.example", &[]),
|
||||
("hint.cmd.show_table.concept", &[]),
|
||||
("hint.cmd.show_tables.what", &[]),
|
||||
("hint.cmd.show_tables.example", &[]),
|
||||
("hint.cmd.show_relationships.what", &[]),
|
||||
("hint.cmd.show_relationships.example", &[]),
|
||||
("hint.cmd.show_relationships.concept", &[]),
|
||||
("hint.cmd.show_indexes.what", &[]),
|
||||
("hint.cmd.show_indexes.example", &[]),
|
||||
("hint.cmd.show_indexes.concept", &[]),
|
||||
("hint.cmd.seed.what", &[]),
|
||||
("hint.cmd.seed.example", &[]),
|
||||
("hint.cmd.seed.concept", &[]),
|
||||
("hint.cmd.explain.what", &[]),
|
||||
("hint.cmd.explain.example", &[]),
|
||||
("hint.cmd.explain.concept", &[]),
|
||||
("hint.cmd.replay.what", &[]),
|
||||
("hint.cmd.replay.example", &[]),
|
||||
("hint.cmd.replay.concept", &[]),
|
||||
// Phase C batch 4 — advanced-mode SQL command hints.
|
||||
("hint.cmd.sql_create_table.what", &[]),
|
||||
("hint.cmd.sql_create_table.example", &[]),
|
||||
("hint.cmd.sql_create_table.concept", &[]),
|
||||
("hint.cmd.sql_alter_table.what", &[]),
|
||||
("hint.cmd.sql_alter_table.example", &[]),
|
||||
("hint.cmd.sql_alter_table.concept", &[]),
|
||||
("hint.cmd.sql_create_index.what", &[]),
|
||||
("hint.cmd.sql_create_index.example", &[]),
|
||||
("hint.cmd.sql_create_index.concept", &[]),
|
||||
("hint.cmd.sql_drop_index.what", &[]),
|
||||
("hint.cmd.sql_drop_index.example", &[]),
|
||||
("hint.cmd.sql_drop_index.concept", &[]),
|
||||
("hint.cmd.sql_drop_table.what", &[]),
|
||||
("hint.cmd.sql_drop_table.example", &[]),
|
||||
("hint.cmd.sql_drop_table.concept", &[]),
|
||||
("hint.cmd.sql_insert.what", &[]),
|
||||
("hint.cmd.sql_insert.example", &[]),
|
||||
("hint.cmd.sql_insert.concept", &[]),
|
||||
("hint.cmd.sql_update.what", &[]),
|
||||
("hint.cmd.sql_update.example", &[]),
|
||||
("hint.cmd.sql_update.concept", &[]),
|
||||
("hint.cmd.sql_delete.what", &[]),
|
||||
("hint.cmd.sql_delete.example", &[]),
|
||||
("hint.cmd.sql_delete.concept", &[]),
|
||||
("hint.cmd.select.what", &[]),
|
||||
("hint.cmd.select.example", &[]),
|
||||
("hint.cmd.select.concept", &[]),
|
||||
("hint.cmd.with.what", &[]),
|
||||
("hint.cmd.with.example", &[]),
|
||||
("hint.cmd.with.concept", &[]),
|
||||
("hint.cmd.explain_sql.what", &[]),
|
||||
("hint.cmd.explain_sql.example", &[]),
|
||||
("hint.cmd.explain_sql.concept", &[]),
|
||||
("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
|
||||
// column-name role reads as the dominant first move.
|
||||
("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", &[]),
|
||||
@@ -258,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"]),
|
||||
@@ -298,6 +481,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.rename_column", &[]),
|
||||
("parse.usage.export", &[]),
|
||||
("parse.usage.help", &[]),
|
||||
("parse.usage.hint", &[]),
|
||||
("parse.usage.import", &[]),
|
||||
("parse.usage.copy", &[]),
|
||||
("parse.usage.load", &[]),
|
||||
@@ -305,6 +489,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.mode", &[]),
|
||||
("parse.usage.new", &[]),
|
||||
("parse.usage.quit", &[]),
|
||||
("parse.usage.version", &[]),
|
||||
("parse.usage.rebuild", &[]),
|
||||
("parse.usage.redo", &[]),
|
||||
("parse.usage.replay", &[]),
|
||||
@@ -391,14 +576,12 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
|
||||
),
|
||||
("select.internal_table", &["table"]),
|
||||
(
|
||||
"cli.invalid_value",
|
||||
&["flag", "value", "expected"],
|
||||
),
|
||||
("cli.invalid_value", &["flag", "value", "expected"]),
|
||||
("cli.missing_value", &["flag"]),
|
||||
("cli.multiple_paths", &["first", "second"]),
|
||||
("cli.resume_with_path", &[]),
|
||||
("cli.unknown_argument", &["arg"]),
|
||||
("cli.version_line", &["version"]),
|
||||
(
|
||||
"archive.export_sequence_exhausted",
|
||||
&["project", "target_dir", "limit"],
|
||||
@@ -445,6 +628,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("undo.redo_failed", &["error"]),
|
||||
// ---- Status bar + panels ----
|
||||
("panel.hint_empty", &[]),
|
||||
("panel.hint_mode_advanced", &[]),
|
||||
("panel.hint_title", &[]),
|
||||
("panel.output_title", &[]),
|
||||
("panel.relationships_empty", &[]),
|
||||
@@ -461,18 +645,26 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("save.title_as", &[]),
|
||||
("save.title_save", &[]),
|
||||
// ---- Shortcut hint labels ----
|
||||
("shortcut.advanced_once", &[]),
|
||||
("shortcut.back_to_list", &[]),
|
||||
("shortcut.browse", &[]),
|
||||
("shortcut.browse_path", &[]),
|
||||
("shortcut.cancel", &[]),
|
||||
("shortcut.cancel_one_shot", &[]),
|
||||
("shortcut.clear", &[]),
|
||||
("shortcut.complete", &[]),
|
||||
("shortcut.confirm", &[]),
|
||||
("shortcut.cycle", &[]),
|
||||
("shortcut.del_word", &[]),
|
||||
("shortcut.hint", &[]),
|
||||
("shortcut.history", &[]),
|
||||
("shortcut.home_end", &[]),
|
||||
("shortcut.load", &[]),
|
||||
("shortcut.nav", &[]),
|
||||
("shortcut.next_pane", &[]),
|
||||
("shortcut.no", &[]),
|
||||
("shortcut.quit", &[]),
|
||||
("shortcut.run", &[]),
|
||||
("shortcut.scroll", &[]),
|
||||
("shortcut.select", &[]),
|
||||
("shortcut.submit", &[]),
|
||||
("shortcut.switch", &[]),
|
||||
("shortcut.to_input", &[]),
|
||||
("shortcut.yes", &[]),
|
||||
// ---- mode / messages banners ----
|
||||
("messages.set_short", &[]),
|
||||
@@ -672,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;
|
||||
@@ -695,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
@@ -34,8 +34,8 @@ pub mod keys;
|
||||
pub mod translate;
|
||||
|
||||
pub use error::{DiagnosticTable, FriendlyError};
|
||||
pub use format::{catalog, Catalog};
|
||||
pub use translate::{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
|
||||
|
||||
@@ -162,6 +162,10 @@ error:
|
||||
# ---- Help text (CLI banner + in-app `help` command) ------------------
|
||||
# ---- CLI argument-parsing errors (stderr before TUI starts) ---------
|
||||
cli:
|
||||
# Version line for `--version` / `-V` and the in-app `version` command
|
||||
# (ADR-0054). `{version}` is `CARGO_PKG_VERSION` — the single source of
|
||||
# truth, equal to the `v*` release tag (release CI guards the match).
|
||||
version_line: "rdbms-playground {version}"
|
||||
missing_value: "missing value for --{flag}"
|
||||
invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})"
|
||||
unknown_argument: "unknown argument: {arg}"
|
||||
@@ -186,6 +190,7 @@ help:
|
||||
|
||||
Options:
|
||||
-h, --help Print this help and exit.
|
||||
-V, --version Print the version and exit.
|
||||
--theme <light|dark> Override theme (default: auto-detect).
|
||||
--data-dir <PATH> Use PATH as the data root instead of
|
||||
the OS-standard location for this run.
|
||||
@@ -210,6 +215,7 @@ help:
|
||||
|
||||
App-level commands (typed inside the app, available in both modes):
|
||||
quit Exit cleanly.
|
||||
version Print the application version.
|
||||
mode simple|advanced Switch input mode.
|
||||
help Show this list of commands in-app.
|
||||
save Save the current temp project under a
|
||||
@@ -256,6 +262,10 @@ help:
|
||||
help: |-
|
||||
help — show this command list
|
||||
help <command> — detailed help for one command (e.g. `help insert`)
|
||||
hint: |-
|
||||
hint — explain the most recent error (press F1 for a hint on what you're typing)
|
||||
version: |-
|
||||
version — print the application version (same as the `--version` command-line flag)
|
||||
rebuild: |-
|
||||
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
|
||||
save: |-
|
||||
@@ -386,6 +396,263 @@ hint:
|
||||
ambient_complete: "Submit with Enter"
|
||||
ambient_expected: "Next: {expected}"
|
||||
ambient_error_with_usage: "{message} — usage: {usage}"
|
||||
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
|
||||
# to expand on (no recent error, empty input).
|
||||
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
|
||||
# Tier-3 block scaffolding (ADR-0053 D4): the heading + the labels the
|
||||
# `what` / `example` / `concept` parts render under.
|
||||
block:
|
||||
heading: "Hint"
|
||||
what: "What"
|
||||
example: "Example"
|
||||
concept: "Concept"
|
||||
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
|
||||
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
||||
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
||||
# (one runnable, mode-correct line) / `concept` (the relational idea —
|
||||
# the teaching part). Phase B seeds the three approved exemplars; the
|
||||
# rest are authored in Phase C.
|
||||
cmd:
|
||||
insert:
|
||||
what: "Add one or more rows to a table."
|
||||
example: "insert into Customers values ('Ann', 'ann@example.io')"
|
||||
concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out."
|
||||
add_relationship:
|
||||
what: "Link two tables so a parent row can own many child rows."
|
||||
example: "add 1:n relationship from Customers.id to Orders.customer_id"
|
||||
concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet."
|
||||
# App-lifecycle commands (Phase C batch 1). Reference-leaning, so
|
||||
# `concept` appears only where there's a real idea to teach.
|
||||
quit:
|
||||
what: "Leave the playground. Your project is already saved to disk."
|
||||
example: "quit"
|
||||
help:
|
||||
what: "List every command, or show the detail for one."
|
||||
example: "help insert"
|
||||
concept: "`help` is the reference; press F1 while typing for a hint about the command you're building right now."
|
||||
hint:
|
||||
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
|
||||
example: "hint"
|
||||
version:
|
||||
what: "Print the application version."
|
||||
example: "version"
|
||||
rebuild:
|
||||
what: "Rebuild the project database from its saved text files."
|
||||
example: "rebuild"
|
||||
concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them."
|
||||
save:
|
||||
what: "Save the current project; `save as` copies it to a new name or location."
|
||||
example: "save as"
|
||||
concept: "On a temporary project, `save` opens a prompt to give it a permanent name; a named project auto-saves as you work, so `save` on one is already done. `save as` always prompts for a new name or path — use it to copy a project."
|
||||
new:
|
||||
what: "Close the current project and start a fresh temporary one."
|
||||
example: "new"
|
||||
load:
|
||||
what: "Open the project picker to switch to a saved project."
|
||||
example: "load"
|
||||
export:
|
||||
what: "Write a shareable zip of the project — its text files only, never the database."
|
||||
example: "export my-shop.zip"
|
||||
concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it."
|
||||
import:
|
||||
what: "Unpack a project zip into a new project and switch to it."
|
||||
example: "import my-shop.zip as shop_copy"
|
||||
mode:
|
||||
what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)."
|
||||
example: "mode advanced"
|
||||
concept: "Simple mode uses keyword commands; advanced mode lets you write SQL directly. A leading `:` runs a single advanced command without switching modes."
|
||||
messages:
|
||||
what: "Show or set how much detail error messages give."
|
||||
example: "messages short"
|
||||
concept: "Verbose (the default) adds a fix-it hint under each error headline; short shows just the headline."
|
||||
undo:
|
||||
what: "Undo the most recent change, after a confirmation."
|
||||
example: "undo"
|
||||
concept: "Every data or schema change is snapshotted first, so you can step back; `redo` re-applies what you undid."
|
||||
redo:
|
||||
what: "Re-apply the most recently undone change."
|
||||
example: "redo"
|
||||
copy:
|
||||
what: "Copy the output panel to the clipboard — all of it, or just the last command's output."
|
||||
example: "copy last"
|
||||
# DDL — schema-shaping commands (Phase C batch 2).
|
||||
create_table:
|
||||
what: "Create a new table and declare its primary key."
|
||||
example: "create table Customers with pk id(serial)"
|
||||
concept: "A table is a set of rows sharing the same columns. `with pk` declares the primary key — one column, or several for a compound key; add the other columns afterwards with `add column`. A `serial` key numbers the rows for you."
|
||||
create_m2n:
|
||||
what: "Create a junction table linking two tables many-to-many."
|
||||
example: "create m:n relationship from Students to Courses"
|
||||
concept: "A many-to-many link (a student takes many courses; a course has many students) can't live in either table, so it gets its own junction table holding a foreign key to each side."
|
||||
add_column:
|
||||
what: "Add a new column to an existing table."
|
||||
example: "add column Customers: phone (text)"
|
||||
concept: "Existing rows take the column's default, or null. A `not null` column with no default can't be added to a table that already has rows — there'd be nothing to put in them."
|
||||
add_index:
|
||||
what: "Create an index on one or more columns to speed up lookups."
|
||||
example: "add index as idx_email on Customers (email)"
|
||||
concept: "An index is a sorted side-structure that makes a lookup like `where email = …` fast, at the cost of a little space and slightly slower writes."
|
||||
add_constraint:
|
||||
what: "Add a constraint — not null, unique, default, or check — to an existing column."
|
||||
example: "add constraint not null to Customers.email"
|
||||
concept: "A constraint is a rule the database enforces on every row. Adding one fails if existing rows already break it, so you fix the data first."
|
||||
drop_table:
|
||||
what: "Remove a table and all of its rows."
|
||||
example: "drop table Customers"
|
||||
concept: "If other tables reference this one through a relationship, drop those relationships (or their child rows) first — the database won't orphan them."
|
||||
drop_column:
|
||||
what: "Remove a column from a table."
|
||||
example: "drop column Customers: phone"
|
||||
concept: "The column's values are lost. You can't drop a primary-key column, or one a relationship depends on."
|
||||
drop_relationship:
|
||||
what: "Remove a relationship between two tables."
|
||||
example: "drop relationship customer_orders"
|
||||
concept: "This drops the foreign-key link and stops the database enforcing it; the tables and their rows stay. The foreign-key column itself remains unless you also drop it."
|
||||
drop_index:
|
||||
what: "Remove an index by name."
|
||||
example: "drop index idx_email"
|
||||
concept: "Only the lookup shortcut goes — the data is untouched. Queries still work, just without that speed-up."
|
||||
drop_constraint:
|
||||
what: "Remove a constraint from a column."
|
||||
example: "drop constraint not null from Customers.email"
|
||||
concept: "The rule stops being enforced from now on; rows already stored are left as they are."
|
||||
rename_column:
|
||||
what: "Rename a column, keeping its values and type."
|
||||
example: "rename column Customers: email to contact_email"
|
||||
concept: "Only the name changes — the stored data is the same. References to the column are reconciled so nothing breaks."
|
||||
change_column:
|
||||
what: "Change a column's type, converting the existing values."
|
||||
example: "change column Customers: status (int)"
|
||||
concept: "The database converts each stored value to the new type; if a value can't convert it refuses the change, so you don't silently lose data. Flags let you force or skip the conversion."
|
||||
# DML — querying and changing data (Phase C batch 3).
|
||||
update:
|
||||
what: "Change values in the rows that match a condition."
|
||||
example: "update Customers set email = 'new@example.io' where id = 1"
|
||||
concept: "The `where` clause picks which rows change, and it's required — pass `--all-rows` to change the whole table on purpose — so you never update more than you meant to."
|
||||
delete:
|
||||
what: "Remove the rows that match a condition."
|
||||
example: "delete from Orders where status = 'cancelled'"
|
||||
concept: "A `where` is required (use `--all-rows` to clear the table on purpose). Rows a relationship points at may be blocked or cascade-deleted, per its `on delete` action."
|
||||
show_data:
|
||||
what: "Show the rows stored in a table."
|
||||
example: "show data Customers"
|
||||
concept: "This reads the data and never changes it. Add a `where` to show only matching rows."
|
||||
show_table:
|
||||
what: "Show a table's structure — its columns, types, keys, and relationships."
|
||||
example: "show table Customers"
|
||||
concept: "Structure, not data: the column definitions and how this table links to others. Use `show data` to see the rows themselves."
|
||||
show_tables:
|
||||
what: "List all the tables in the project."
|
||||
example: "show tables"
|
||||
show_relationships:
|
||||
what: "List all the relationships between tables."
|
||||
example: "show relationships"
|
||||
concept: "Each relationship is a foreign-key link from a child column to a parent's key, with an `on delete` / `on update` rule."
|
||||
show_indexes:
|
||||
what: "List all the indexes in the project."
|
||||
example: "show indexes"
|
||||
concept: "Indexes speed up lookups; this shows which columns each one covers and whether it enforces uniqueness."
|
||||
seed:
|
||||
what: "Fill a table with generated sample rows, or fill one column on existing rows."
|
||||
example: "seed Customers 50"
|
||||
concept: "Seeding invents realistic-looking data so you have something to query. Pin a value with `set col = …`, choose a generator with `as`, or give a numeric range with `between`."
|
||||
explain:
|
||||
what: "Show how the database will run a query — without running it."
|
||||
example: "explain show data Customers where email = 'a@example.io'"
|
||||
concept: "The plan reveals whether the database scans the whole table or jumps straight to rows through an index — the payoff of `add index`. `explain` never executes, so it's safe even on a delete."
|
||||
replay:
|
||||
what: "Re-run the commands recorded in a history file."
|
||||
example: "replay session.log"
|
||||
concept: "Every successful command is journalled, so replaying re-applies them in order to reproduce a project's state — handy for scripting or redoing a sequence."
|
||||
# Advanced-mode SQL forms (Phase C batch 4). Examples are SQL, the
|
||||
# advanced surface — distinct from their simple-mode siblings.
|
||||
sql_create_table:
|
||||
what: "Create a table using SQL syntax (advanced mode)."
|
||||
example: "create table Customers (id int primary key, name text, email text)"
|
||||
concept: "Advanced mode speaks SQL: constraints go inline (`primary key`, `not null`, `unique`, `check`). This is the raw form of simple mode's `create table … with pk …`."
|
||||
sql_alter_table:
|
||||
what: "Change a table's structure with SQL `alter table` (advanced mode)."
|
||||
example: "alter table Customers add column phone text"
|
||||
concept: "`alter table` adds or drops columns, renames, and adds constraints — the SQL equivalent of simple mode's `add column` / `drop column` / `change column`."
|
||||
sql_create_index:
|
||||
what: "Create an index with SQL (advanced mode)."
|
||||
example: "create index ix_email on Customers (email)"
|
||||
concept: "Add `unique` to also forbid duplicate values. The simple-mode equivalent is `add index`."
|
||||
sql_drop_index:
|
||||
what: "Remove an index with SQL (advanced mode)."
|
||||
example: "drop index ix_email"
|
||||
concept: "Only the lookup shortcut goes; the data is untouched. Add `if exists` to ignore a missing index."
|
||||
sql_drop_table:
|
||||
what: "Remove a table with SQL (advanced mode)."
|
||||
example: "drop table Customers"
|
||||
concept: "Add `if exists` to avoid an error when the table might not be there. Relationships pointing at it may block the drop."
|
||||
sql_insert:
|
||||
what: "Insert rows with SQL (advanced mode)."
|
||||
example: "insert into Customers (name, email) values ('Ann', 'ann@example.io')"
|
||||
concept: "Naming the columns lets you supply them in any order and skip ones that have a default — the SQL form of simple mode's `insert`."
|
||||
sql_update:
|
||||
what: "Update rows with SQL (advanced mode)."
|
||||
example: "update Customers set email = 'new@example.io' where id = 1"
|
||||
concept: "`set` lists the new values; `where` picks which rows change. The SQL form of simple mode's `update`."
|
||||
sql_delete:
|
||||
what: "Delete rows with SQL (advanced mode)."
|
||||
example: "delete from Orders where status = 'cancelled'"
|
||||
concept: "`where` picks the rows to remove; foreign-key rules still apply. The SQL form of simple mode's `delete`."
|
||||
select:
|
||||
what: "Query rows with SQL `select` (advanced mode)."
|
||||
example: "select name, email from Customers where id = 1"
|
||||
concept: "`select` is read-only: choose columns (or `*`), filter with `where`, sort with `order by`, cap with `limit`. This is the heart of SQL — and the reason advanced mode exists."
|
||||
with:
|
||||
what: "Name a sub-query (a CTE) and read from it in a `select` (advanced mode)."
|
||||
example: "with recent as (select * from Orders where id > 100) select * from recent"
|
||||
concept: "A `with` clause (Common Table Expression) names a query so the main `select` can use it like a temporary table — handy for breaking a complex query into readable steps."
|
||||
explain_sql:
|
||||
what: "Show how the database will run a SQL query, without running it (advanced mode)."
|
||||
example: "explain select * from Customers where email = 'a@example.io'"
|
||||
concept: "Like simple mode's `explain`, but wraps a raw SQL statement. It reveals whether an index is used, and never executes."
|
||||
err:
|
||||
# Runtime error classes (Phase C batch 5), keyed by
|
||||
# friendly::error_hint_class. `example` is a fix recipe rather than a
|
||||
# runnable line; `concept` is the relational idea behind the rule.
|
||||
foreign_key:
|
||||
child_side:
|
||||
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
|
||||
example: "First insert the parent (insert into Customers …), then the child that references it."
|
||||
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist before a child can reference it. (`on delete` actions like `cascade` or `set null` govern the other direction — what happens to children when their parent is removed — not this one.)"
|
||||
parent_side:
|
||||
what: "You're deleting or changing a row that other rows point at, which would orphan those children."
|
||||
example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)."
|
||||
concept: "A foreign key guarantees every child has a real parent, so the database won't remove a parent out from under its children unless the relationship says what should happen to them."
|
||||
unique:
|
||||
what: "A value you're inserting — or updating to — already exists in a column that must be unique."
|
||||
example: "Pick a different value, or update the existing row instead of inserting a new one."
|
||||
concept: "A unique constraint (and every primary key) forbids duplicates, so each value identifies at most one row."
|
||||
not_null:
|
||||
what: "You left a column empty that is required to have a value."
|
||||
example: "Supply a value for the column, or give it a default so new rows fill it automatically."
|
||||
concept: "A `not null` constraint means every row must have a value there — it's how you mark a fact as mandatory."
|
||||
check:
|
||||
what: "A value broke a `check` rule defined on the column."
|
||||
example: "Use a value the rule allows — for example a positive number, or one of the permitted options."
|
||||
concept: "A `check` constraint is a condition every row must satisfy, so the database enforces business rules like \"price ≥ 0\" for you."
|
||||
type_mismatch:
|
||||
what: "A value doesn't fit the column's type — for instance text where a number is expected."
|
||||
example: "Give a value of the right type: a number for `int`/`real`, a quoted string for `text`, true/false for `bool`."
|
||||
concept: "Every column has a type, and the database rejects values that don't fit, so a column's data stays consistent and comparable."
|
||||
not_found:
|
||||
what: "You named a table or column that doesn't exist."
|
||||
example: "Check the spelling, or run `show tables` (or `show table <name>`) to see what's there."
|
||||
concept: "A command can only refer to tables and columns that already exist — create them first if you need them."
|
||||
already_exists:
|
||||
what: "You tried to create a table, column, relationship, or index whose name is already taken."
|
||||
example: "Pick a different name, or drop the existing one first if you meant to replace it."
|
||||
concept: "Names must be unique within their kind so a command is never ambiguous about what it refers to."
|
||||
generic:
|
||||
what: "The database refused the command for the reason shown above."
|
||||
example: "Read that message for the specifics, adjust the command, and try again."
|
||||
invalid_value:
|
||||
what: "A value or option in the command wasn't valid for where it was used."
|
||||
example: "Check the value against the column's type and the command's accepted options."
|
||||
# Invalid identifier in a schema slot (ADR-0022 stage 8e
|
||||
# + the user's #5). Voice mirrors ADR-0019's "no such
|
||||
# {kind}" wording for consistency with engine errors.
|
||||
@@ -400,6 +667,12 @@ hint:
|
||||
# at `create table T (` so the column-name role is visible
|
||||
# alongside the table-level constraint keywords.
|
||||
create_table_element: "Type a column name, or a table-level constraint: `primary`, `unique`, `check`, `constraint`, `foreign`"
|
||||
# Issue #26: the `seed <table> ▮` position. The optional row count is
|
||||
# a bare number with no Tab candidate, so it (and the `.column`
|
||||
# column-fill form) would be invisible next to the `set` / `--seed`
|
||||
# chips. Names every option so the most common next move (a count) is
|
||||
# discoverable.
|
||||
seed_count: "Optionally a row count, e.g. `50` (default 20); `.column` to fill one column on existing rows; `set` to pin a column; `--seed` to fix the RNG"
|
||||
# Value-literal slot — `insert ... values (`, `update ... set
|
||||
# col=`, `where col=`. Replaces the misleading "null true
|
||||
# false" keyword candidate list with format guidance for all
|
||||
@@ -611,6 +884,8 @@ parse:
|
||||
# description.
|
||||
quit: "quit"
|
||||
help: "help [<command>]"
|
||||
hint: "hint"
|
||||
version: "version"
|
||||
rebuild: "rebuild"
|
||||
save: "save | save as"
|
||||
new: "new"
|
||||
@@ -877,14 +1152,21 @@ panel:
|
||||
relationships_title: "Relationships"
|
||||
relationships_empty: "(none)"
|
||||
hint_empty: "Type a command — press Tab for options, `help` for a list"
|
||||
# Mode-discovery pointer appended to the empty-input hint in SIMPLE
|
||||
# mode (ADR-0051): the `mode advanced` switch left the keybinding
|
||||
# strip, so the hint advertises it. Leading separator continues the
|
||||
# prompt line. Advanced mode shows no pointer — users know how they
|
||||
# got there, and `help` covers the way back.
|
||||
hint_mode_advanced: " · `mode advanced` for SQL"
|
||||
# Panel titles for the output and hint panels (rendered inside
|
||||
# the rounded border, hence the leading/trailing space).
|
||||
output_title: "Output"
|
||||
hint_title: "Hint"
|
||||
|
||||
# ---- Shortcut hints (paired with key names in the bottom bar) -------
|
||||
# The bottom strip is keystrokes-only and state-aware (ADR-0051). Labels
|
||||
# pair with a key name in the renderer (e.g. `Enter` + `run`).
|
||||
shortcut:
|
||||
submit: "submit"
|
||||
confirm: "confirm"
|
||||
cancel: "cancel"
|
||||
yes: "Yes"
|
||||
@@ -893,10 +1175,20 @@ shortcut:
|
||||
select: "select"
|
||||
browse_path: "browse path"
|
||||
back_to_list: "back to list"
|
||||
switch: "switch"
|
||||
advanced_once: "advanced once"
|
||||
cancel_one_shot: "cancel one-shot"
|
||||
quit: "quit"
|
||||
# Status-strip labels (ADR-0051, issue #27).
|
||||
run: "run"
|
||||
nav: "sidebar"
|
||||
next_pane: "next pane"
|
||||
scroll: "scroll"
|
||||
to_input: "input"
|
||||
cycle: "cycle"
|
||||
browse: "browse"
|
||||
clear: "clear"
|
||||
complete: "complete"
|
||||
hint: "hint"
|
||||
history: "history"
|
||||
home_end: "home/end"
|
||||
del_word: "del word"
|
||||
|
||||
# ---- mode / messages banners (app-level commands) -------------------
|
||||
mode:
|
||||
|
||||
+224
-68
@@ -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
|
||||
@@ -253,11 +249,74 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
|
||||
fe
|
||||
}
|
||||
|
||||
fn translate_sqlite(
|
||||
/// The tier-3 hint class (`hint.err.<class>`) for an error.
|
||||
///
|
||||
/// The same classification [`translate`] performs, surfaced as a
|
||||
/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns
|
||||
/// `None` for internal / fatal errors that carry no learner-facing
|
||||
/// hint (persistence, IO, worker-gone).
|
||||
///
|
||||
/// **Keep in sync with [`translate`] / `translate_sqlite` /
|
||||
/// `translate_constraint` / `translate_foreign_key`** — the unit tests
|
||||
/// below pin each class.
|
||||
#[must_use]
|
||||
pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> {
|
||||
match error {
|
||||
DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx),
|
||||
DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"),
|
||||
DbError::PersistenceFatal { .. }
|
||||
| DbError::RebuildRowFailed { .. }
|
||||
| DbError::Io(_)
|
||||
| DbError::WorkerGone => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_hint_class(
|
||||
message: &str,
|
||||
kind: SqliteErrorKind,
|
||||
ctx: &TranslateContext,
|
||||
) -> FriendlyError {
|
||||
) -> Option<&'static str> {
|
||||
if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
|
||||
return Some("type_mismatch");
|
||||
}
|
||||
Some(match kind {
|
||||
SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found",
|
||||
SqliteErrorKind::AlreadyExists => "already_exists",
|
||||
SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx),
|
||||
SqliteErrorKind::Other => "generic",
|
||||
})
|
||||
}
|
||||
|
||||
fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("unique constraint failed") {
|
||||
"unique"
|
||||
} else if lower.contains("foreign key constraint failed") {
|
||||
fk_hint_class(ctx)
|
||||
} else if lower.contains("not null constraint failed") {
|
||||
"not_null"
|
||||
} else if lower.contains("check constraint failed") {
|
||||
"check"
|
||||
} else {
|
||||
"generic"
|
||||
}
|
||||
}
|
||||
|
||||
const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
|
||||
// Mirrors `translate_foreign_key`'s side disambiguation.
|
||||
if ctx.parent_table.is_some() {
|
||||
return "foreign_key.child_side";
|
||||
}
|
||||
if ctx.child_table.is_some() {
|
||||
return "foreign_key.parent_side";
|
||||
}
|
||||
match ctx.operation {
|
||||
Some(Operation::Delete) => "foreign_key.parent_side",
|
||||
_ => "foreign_key.child_side",
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -325,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(
|
||||
@@ -338,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
|
||||
@@ -358,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),
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -475,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!(
|
||||
@@ -509,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(
|
||||
|| {
|
||||
@@ -546,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))
|
||||
}
|
||||
|
||||
@@ -589,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!(
|
||||
@@ -629,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.
|
||||
@@ -722,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
|
||||
@@ -780,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)]
|
||||
@@ -798,6 +845,107 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── H2 / ADR-0053: error → tier-3 hint class ────────────────
|
||||
|
||||
#[test]
|
||||
fn hint_class_maps_runtime_error_kinds() {
|
||||
use crate::db::{DbError, SqliteErrorKind};
|
||||
let sqlite = |kind, msg: &str| DbError::Sqlite {
|
||||
message: msg.to_string(),
|
||||
kind,
|
||||
};
|
||||
let d = TranslateContext::default;
|
||||
assert_eq!(
|
||||
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()
|
||||
),
|
||||
Some("not_found")
|
||||
);
|
||||
assert_eq!(
|
||||
error_hint_class(
|
||||
&sqlite(SqliteErrorKind::AlreadyExists, "already exists"),
|
||||
&d()
|
||||
),
|
||||
Some("already_exists")
|
||||
);
|
||||
assert_eq!(
|
||||
error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()),
|
||||
Some("generic")
|
||||
);
|
||||
// Constraint-violation message splitting.
|
||||
let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg);
|
||||
assert_eq!(
|
||||
error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()),
|
||||
Some("unique")
|
||||
);
|
||||
assert_eq!(
|
||||
error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()),
|
||||
Some("not_null")
|
||||
);
|
||||
assert_eq!(
|
||||
error_hint_class(&cv("CHECK constraint failed: T"), &d()),
|
||||
Some("check")
|
||||
);
|
||||
// change-column op routes any engine error to type_mismatch.
|
||||
assert_eq!(
|
||||
error_hint_class(
|
||||
&sqlite(SqliteErrorKind::Other, "x"),
|
||||
&ctx_with(Operation::ChangeColumnType)
|
||||
),
|
||||
Some("type_mismatch")
|
||||
);
|
||||
// App-level refusals and internal/fatal errors.
|
||||
assert_eq!(
|
||||
error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()),
|
||||
Some("invalid_value")
|
||||
);
|
||||
assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_class_resolves_foreign_key_sides() {
|
||||
use crate::db::{DbError, SqliteErrorKind};
|
||||
let fk = || DbError::Sqlite {
|
||||
message: "FOREIGN KEY constraint failed".to_string(),
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
};
|
||||
// Enrichment: parent_table populated → child-side.
|
||||
let ctx = TranslateContext {
|
||||
parent_table: Some("Parent".to_string()),
|
||||
..TranslateContext::default()
|
||||
};
|
||||
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")
|
||||
);
|
||||
// No enrichment: operation is the tiebreaker.
|
||||
assert_eq!(
|
||||
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
|
||||
Some("foreign_key.parent_side")
|
||||
);
|
||||
assert_eq!(
|
||||
error_hint_class(&fk(), &ctx_with(Operation::Insert)),
|
||||
Some("foreign_key.child_side")
|
||||
);
|
||||
}
|
||||
|
||||
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
|
||||
DbError::Sqlite {
|
||||
message: message.to_string(),
|
||||
@@ -896,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]
|
||||
|
||||
+215
-132
@@ -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 {
|
||||
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; // ": "
|
||||
@@ -1356,6 +1361,100 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_cache() -> crate::completion::SchemaCache {
|
||||
use crate::completion::TableColumn;
|
||||
use crate::dsl::types::Type;
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_is_advertised_at_the_optional_position() {
|
||||
// Issue #26: `seed users ▮` is a complete command, so the hint
|
||||
// ladder shows only the `set` / `--seed` continuation chips —
|
||||
// the optional row count (a bare number with no candidate) was
|
||||
// invisible. An IntroProse hint that survives the trailing
|
||||
// optionals now advertises it; Tab still cycles the keywords.
|
||||
let cache = seed_cache();
|
||||
let input = "seed users ";
|
||||
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
assert!(
|
||||
p.contains("row count") && p.contains("20"),
|
||||
"prose must mention the row count and the default; got: {p:?}",
|
||||
);
|
||||
assert!(
|
||||
p.contains("set") && p.contains("--seed") && p.contains(".column"),
|
||||
"prose should fold in the keyword + column-fill options; got: {p:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected a Prose count hint; got: {other:?}"),
|
||||
}
|
||||
// Tab candidates remain available (completion is independent).
|
||||
let comp = crate::completion::candidates_at_cursor_in_mode(
|
||||
input,
|
||||
input.len(),
|
||||
&cache,
|
||||
Mode::Simple,
|
||||
)
|
||||
.expect("completion remains available");
|
||||
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
assert!(
|
||||
texts.contains(&"set") && texts.contains(&"--seed"),
|
||||
"Tab must still cycle `set` / `--seed`; got {texts:?}",
|
||||
);
|
||||
|
||||
// `seed` runs in both modes (ADR-0048), so the hint must fire in
|
||||
// advanced mode too — not only simple.
|
||||
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
|
||||
Some(AmbientHint::Prose(p)) => assert!(
|
||||
p.contains("row count"),
|
||||
"count hint must also fire in advanced mode; got: {p:?}",
|
||||
),
|
||||
other => panic!("expected the count hint in advanced mode; got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() {
|
||||
// Position guard: the hint shows only while the cursor sits at
|
||||
// the count slot. Once the count is supplied — or a later clause
|
||||
// consumes input past it — it must not reappear.
|
||||
let cache = seed_cache();
|
||||
for input in ["seed users 50 ", "seed users set email = 'x' "] {
|
||||
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple);
|
||||
let is_count_prose = matches!(
|
||||
&hint,
|
||||
Some(AmbientHint::Prose(p)) if p.contains("row count")
|
||||
);
|
||||
assert!(
|
||||
!is_count_prose,
|
||||
"count hint must not show for {input:?}; got {hint:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_hint_also_fires_after_a_column_fill_target() {
|
||||
// The count is valid after `seed users.email` too, so the hint
|
||||
// fires there — `.email` is a real column (no diagnostic).
|
||||
let cache = seed_cache();
|
||||
let input = "seed users.email ";
|
||||
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
|
||||
Some(AmbientHint::Prose(p)) => assert!(
|
||||
p.contains("row count"),
|
||||
"count hint expected after a column-fill target; got: {p:?}",
|
||||
),
|
||||
other => panic!("expected a Prose count hint; got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() {
|
||||
// Issue #6 trade-off lockdown: dropping the typing-time
|
||||
@@ -1415,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 {
|
||||
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());
|
||||
}
|
||||
@@ -1464,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:?}",),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1565,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,
|
||||
@@ -1591,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) {
|
||||
@@ -1619,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, .. })
|
||||
@@ -1646,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(),
|
||||
@@ -1666,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(),
|
||||
@@ -1686,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(),
|
||||
@@ -1717,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(
|
||||
@@ -1759,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
|
||||
}
|
||||
|
||||
@@ -1773,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",
|
||||
@@ -1786,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());
|
||||
}
|
||||
@@ -1827,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:?}"),
|
||||
}
|
||||
@@ -1855,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) {
|
||||
@@ -1879,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!(
|
||||
@@ -1923,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)) => {
|
||||
@@ -1942,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)) => {
|
||||
@@ -1970,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!(
|
||||
@@ -1984,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:?}"),
|
||||
}
|
||||
@@ -2004,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)) => {
|
||||
@@ -2044,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)) => {
|
||||
@@ -2078,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);
|
||||
@@ -2145,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)) => {
|
||||
@@ -2297,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:?}"),
|
||||
}
|
||||
@@ -2353,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;
|
||||
@@ -2373,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,
|
||||
};
|
||||
@@ -2407,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,
|
||||
};
|
||||
@@ -2477,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
@@ -60,8 +60,8 @@ pub fn init(path: Option<&Path>) -> Result<PathBuf> {
|
||||
.with_context(|| format!("create log directory {}", parent.display()))?;
|
||||
}
|
||||
let file = open_log_file(&chosen)?;
|
||||
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG")
|
||||
.unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let filter =
|
||||
EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let layer = fmt::layer()
|
||||
.with_writer(file)
|
||||
.with_ansi(false)
|
||||
@@ -95,8 +95,7 @@ fn home_dir() -> Option<PathBuf> {
|
||||
if let Some(p) = std::env::var_os("HOME") {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
if let (Some(drive), Some(path)) =
|
||||
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
|
||||
if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
|
||||
{
|
||||
let mut combined = PathBuf::from(drive);
|
||||
combined.push(path);
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
use std::process::ExitCode;
|
||||
|
||||
use rdbms_playground::cli::{help_text, Args};
|
||||
use rdbms_playground::cli::{Args, help_text, version_text};
|
||||
use rdbms_playground::{logging, runtime};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -22,6 +22,11 @@ fn main() -> ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
if args.version {
|
||||
println!("{}", version_text());
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
if args.help {
|
||||
print!("{}", help_text());
|
||||
return ExitCode::SUCCESS;
|
||||
|
||||
+103
-105
@@ -71,27 +71,22 @@ pub fn render_data_table(data: &DataResult) -> Vec<String> {
|
||||
render_table(&header_cells, &body, &alignments)
|
||||
}
|
||||
|
||||
/// Render a table-structure listing.
|
||||
/// Render an incidental-DDL structure echo (ADR-0050, issue #28).
|
||||
///
|
||||
/// Produces a header line (`<TableName>`), the schema table
|
||||
/// itself, and — for a structure that has FK relationships
|
||||
/// — `References:` / `Referenced by:` blocks below as plain
|
||||
/// indented text (relationship visualization is its own
|
||||
/// future ADR per §5 OOS-1).
|
||||
/// Display a relationship-endpoint column list (ADR-0043): the bare
|
||||
/// column for a single-column FK, `(a, b)` for a compound one.
|
||||
fn cols_disp(cols: &[String]) -> String {
|
||||
if cols.len() == 1 {
|
||||
cols[0].clone()
|
||||
} else {
|
||||
format!("({})", cols.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a header line (`<TableName>`), the schema table, the
|
||||
/// `Indexes:` section, and the constraint section — **structure only**.
|
||||
/// Relationship information is deliberately omitted: a confirmation
|
||||
/// echo for a structural edit (`create table`, `add`/`drop`/`rename`/
|
||||
/// `change column`, `add`/`drop index`) reports the change just made,
|
||||
/// not the table's relationships, which the user did not touch. The
|
||||
/// relationship-subject surfaces (`show table`, `add`/`drop
|
||||
/// relationship`) render diagrams via [`render_structure_with_diagrams`]
|
||||
/// instead; relationships are one `show table <T>` away. ADR-0050
|
||||
/// supersedes ADR-0044 §1's "incidental DDL keeps prose" and the
|
||||
/// relationship-block half of ADR-0016 §5.
|
||||
#[must_use]
|
||||
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
let mut out = structure_box_lines(desc);
|
||||
out.extend(relationship_prose_lines(desc));
|
||||
out.extend(index_lines(desc));
|
||||
out.extend(constraint_lines(desc));
|
||||
out
|
||||
@@ -118,41 +113,6 @@ fn structure_box_lines(desc: &TableDescription) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5),
|
||||
/// retained for the incidental DDL echoes (ADR-0044 §1).
|
||||
fn relationship_prose_lines(desc: &TableDescription) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
if !desc.outbound_relationships.is_empty() {
|
||||
out.push("References:".to_string());
|
||||
for r in &desc.outbound_relationships {
|
||||
out.push(format!(
|
||||
" {} → {}.{} ({}, on delete {}, on update {})",
|
||||
cols_disp(&r.local_columns),
|
||||
r.other_table,
|
||||
cols_disp(&r.other_columns),
|
||||
r.name,
|
||||
r.on_delete,
|
||||
r.on_update,
|
||||
));
|
||||
}
|
||||
}
|
||||
if !desc.inbound_relationships.is_empty() {
|
||||
out.push("Referenced by:".to_string());
|
||||
for r in &desc.inbound_relationships {
|
||||
out.push(format!(
|
||||
" {}.{} → {} ({}, on delete {}, on update {})",
|
||||
r.other_table,
|
||||
cols_disp(&r.other_columns),
|
||||
cols_disp(&r.local_columns),
|
||||
r.name,
|
||||
r.on_delete,
|
||||
r.on_update,
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Indexes section (ADR-0025), only when the table carries a
|
||||
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
|
||||
/// §4d).
|
||||
@@ -212,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),
|
||||
@@ -265,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) {
|
||||
@@ -275,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);
|
||||
}
|
||||
}
|
||||
@@ -383,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,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).
|
||||
@@ -832,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
|
||||
@@ -1178,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}"
|
||||
@@ -1277,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(),
|
||||
@@ -1317,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}",
|
||||
@@ -1452,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()),
|
||||
@@ -1591,11 +1547,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_structure_with_relationships() {
|
||||
fn render_structure_omits_relationship_prose() {
|
||||
// ADR-0050 (issue #28): the incidental-DDL structure echo never
|
||||
// carries the `References:` / `Referenced by:` block, even when
|
||||
// the description carries both inbound and outbound
|
||||
// relationships. (Relationship-subject surfaces render diagrams
|
||||
// via `render_structure_with_diagrams`, not this function.)
|
||||
let desc = TableDescription {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![col("id", Type::Serial, true, false)],
|
||||
outbound_relationships: Vec::new(),
|
||||
outbound_relationships: vec![RelationshipEnd {
|
||||
name: "cust_region".to_string(),
|
||||
other_table: "Regions".to_string(),
|
||||
other_columns: vec!["id".to_string()],
|
||||
local_columns: vec!["region_id".to_string()],
|
||||
on_delete: ReferentialAction::NoAction,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
inbound_relationships: vec![RelationshipEnd {
|
||||
name: "cust_orders".to_string(),
|
||||
other_table: "Orders".to_string(),
|
||||
@@ -1609,15 +1577,14 @@ mod tests {
|
||||
check_constraints: Vec::new(),
|
||||
};
|
||||
let out = render_structure(&desc).join("\n");
|
||||
assert!(
|
||||
out.contains("Referenced by:"),
|
||||
"expected inbound relationship section:\n{out}",
|
||||
);
|
||||
assert!(
|
||||
out.contains("Orders.cust_id → id"),
|
||||
"expected inbound relationship line:\n{out}",
|
||||
);
|
||||
assert_snapshot!(out);
|
||||
// The structure box still renders.
|
||||
assert!(out.contains("Customers"), "structure header:\n{out}");
|
||||
assert!(out.contains("│ id"), "column row:\n{out}");
|
||||
// No relationship block in either direction.
|
||||
assert!(!out.contains("References:"), "no outbound prose:\n{out}");
|
||||
assert!(!out.contains("Referenced by:"), "no inbound prose:\n{out}");
|
||||
assert!(!out.contains("Orders.cust_id"), "no prose line:\n{out}");
|
||||
assert!(!out.contains("Regions"), "no prose line:\n{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1663,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]
|
||||
@@ -1706,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(),
|
||||
@@ -1720,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]
|
||||
@@ -1761,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"));
|
||||
@@ -1804,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"),
|
||||
@@ -1830,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
@@ -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"));
|
||||
|
||||
+144
-21
@@ -28,7 +28,35 @@ use super::PersistenceError;
|
||||
pub(super) const STATUS_OK: &str = "ok";
|
||||
pub(super) const STATUS_ERR: &str = "err";
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O.
|
||||
/// The optional status suffix marking an advanced-mode submission
|
||||
/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that
|
||||
/// hydration can reconstruct the `:`-prefixed runnable form of an
|
||||
/// advanced command, making advanced history reusable in simple mode.
|
||||
pub(super) const ADV_SUFFIX: &str = "adv";
|
||||
|
||||
/// Build the status token for a `base` (`ok`/`err`) and submission mode.
|
||||
pub(super) fn status_token(base: &str, advanced: bool) -> String {
|
||||
if advanced {
|
||||
format!("{base}:{ADV_SUFFIX}")
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base
|
||||
/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional
|
||||
/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so
|
||||
/// replay skips it rather than mis-running it.
|
||||
pub(super) fn parse_status(status: &str) -> (bool, bool) {
|
||||
let (base, suffix) = status.split_once(':').unwrap_or((status, ""));
|
||||
(base == STATUS_OK, suffix == ADV_SUFFIX)
|
||||
}
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O. (Simple-mode
|
||||
/// convenience used by tests; production threads the mode through
|
||||
/// [`format_record_with_status`] + [`status_token`], so this is
|
||||
/// test-only since ADR-0052.)
|
||||
#[cfg(test)]
|
||||
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
|
||||
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
|
||||
}
|
||||
@@ -80,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);
|
||||
@@ -100,9 +125,20 @@ fn parse_record_source(line: &str) -> Option<String> {
|
||||
// characters) is preserved.
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let _ts = parts.next()?;
|
||||
let _status = parts.next()?;
|
||||
let status = parts.next()?;
|
||||
let source = parts.next()?;
|
||||
Some(unescape_command(source))
|
||||
let (_is_ok, advanced) = parse_status(status);
|
||||
let command = unescape_command(source);
|
||||
// ADR-0052: an advanced record is hydrated in its `:`-prefixed
|
||||
// simple-mode runnable form, so cross-session recall matches the
|
||||
// in-session ring (and recall strips the `:` again in advanced
|
||||
// mode). A simple record hydrates bare. Old `ok`/`err` logs have no
|
||||
// `:adv` suffix → read as simple, unchanged.
|
||||
Some(if advanced {
|
||||
format!(": {command}")
|
||||
} else {
|
||||
command
|
||||
})
|
||||
}
|
||||
|
||||
/// A parsed journal record (ADR-0034 §3). `source` is already
|
||||
@@ -129,8 +165,11 @@ pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
|
||||
if !looks_like_iso8601(ts) {
|
||||
return None;
|
||||
}
|
||||
// ADR-0052: the status may carry a `:adv` mode suffix; replayability
|
||||
// keys off the base token only (`ok` / `ok:adv` are both ok).
|
||||
let (status_is_ok, _advanced) = parse_status(status);
|
||||
Some(JournalRecord {
|
||||
status_is_ok: status == STATUS_OK,
|
||||
status_is_ok,
|
||||
source: unescape_command(source),
|
||||
})
|
||||
}
|
||||
@@ -145,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 {
|
||||
@@ -279,9 +332,7 @@ 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)",
|
||||
)
|
||||
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)");
|
||||
@@ -328,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");
|
||||
}
|
||||
|
||||
@@ -364,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]
|
||||
@@ -395,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]
|
||||
@@ -436,4 +493,70 @@ mod tests {
|
||||
let body = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
|
||||
}
|
||||
|
||||
// ---- ADR-0052 (issue #30): mode tag in the status field ----
|
||||
|
||||
#[test]
|
||||
fn status_token_builds_and_parses_the_adv_suffix() {
|
||||
assert_eq!(status_token(STATUS_OK, false), "ok");
|
||||
assert_eq!(status_token(STATUS_OK, true), "ok:adv");
|
||||
assert_eq!(status_token(STATUS_ERR, true), "err:adv");
|
||||
assert_eq!(parse_status("ok"), (true, false));
|
||||
assert_eq!(parse_status("ok:adv"), (true, true));
|
||||
assert_eq!(parse_status("err"), (false, false));
|
||||
assert_eq!(parse_status("err:adv"), (false, true));
|
||||
// Unknown base → not ok (replay skips it), simple.
|
||||
assert_eq!(parse_status("frobnicate"), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_reconstructs_colon_prefix_for_advanced() {
|
||||
// An advanced record (`ok:adv`) hydrates in its `:`-prefixed
|
||||
// simple-mode runnable form; a simple record stays bare. This is
|
||||
// the cross-session half of the issue #30 fix.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
let adv = format_record_with_status(
|
||||
"select * from T",
|
||||
"2026-06-13T10:00:00Z".to_string(),
|
||||
&status_token(STATUS_OK, true),
|
||||
);
|
||||
let simple = format_record_with_status(
|
||||
"create table T with pk",
|
||||
"2026-06-13T10:00:01Z".to_string(),
|
||||
&status_token(STATUS_OK, false),
|
||||
);
|
||||
std::fs::write(&path, format!("{adv}{simple}")).unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
": select * from T".to_string(),
|
||||
"create table T with pk".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_treats_ok_adv_as_ok() {
|
||||
// Replay keys off the base token, so `ok:adv` replays like `ok`
|
||||
// (source stays canonical).
|
||||
let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T")
|
||||
.expect("ok:adv journal record");
|
||||
assert!(rec.status_is_ok);
|
||||
assert_eq!(rec.source, "select * from T");
|
||||
let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad")
|
||||
.expect("err:adv journal record");
|
||||
assert!(!err.status_is_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_three_field_log_reads_as_simple() {
|
||||
// Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(got, vec!["select 1".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
®istry_with_v1_to_v2(),
|
||||
tmp.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = migrate_to_latest(&v1_body(), ®istry_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(),
|
||||
®istry_with_v1_to_v2(),
|
||||
)
|
||||
.unwrap();
|
||||
let outcome = ensure_project_yaml_migrated(tmp.path(), ®istry_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],
|
||||
};
|
||||
|
||||
+39
-16
@@ -368,8 +368,7 @@ impl Persistence {
|
||||
path: data_dir.clone(),
|
||||
source,
|
||||
})?;
|
||||
let body =
|
||||
csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
|
||||
let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
|
||||
kind: "CSV",
|
||||
path: data_dir.join(format!("{}.csv", table.name)),
|
||||
message,
|
||||
@@ -395,11 +394,23 @@ impl Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one successful-command record to `history.log`.
|
||||
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
/// Append one successful-command record to `history.log`. `advanced`
|
||||
/// (ADR-0052) tags the record `ok:adv` when the command was submitted
|
||||
/// in an advanced effective mode, so hydration can reconstruct its
|
||||
/// `:`-prefixed form for reuse in simple mode.
|
||||
pub fn append_history(
|
||||
&self,
|
||||
command_text: &str,
|
||||
advanced: bool,
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
||||
debug!(len = command_text.len(), "persist: append ok record to 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);
|
||||
debug!(
|
||||
len = command_text.len(),
|
||||
advanced, "persist: append ok record to history.log"
|
||||
);
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
@@ -410,14 +421,19 @@ impl Persistence {
|
||||
/// transactional `ok` journal). Best-effort at the call site:
|
||||
/// a failure to record a failure must never escalate a user
|
||||
/// error into a fatal (ADR-0034 §4).
|
||||
pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
pub fn append_history_failure(
|
||||
&self,
|
||||
command_text: &str,
|
||||
advanced: bool,
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
history::STATUS_ERR,
|
||||
let status = history::status_token(history::STATUS_ERR, advanced);
|
||||
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"
|
||||
);
|
||||
debug!(len = command_text.len(), "persist: append err record to history.log");
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
@@ -508,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");
|
||||
}
|
||||
|
||||
@@ -577,8 +599,9 @@ 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)").unwrap();
|
||||
p.append_history("insert into Foo (1)").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();
|
||||
assert_eq!(lines.len(), 2);
|
||||
|
||||
+116
-34
@@ -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,12 +290,13 @@ 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,
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user