Merge branch 'main' into website (Gitea migration + ADR renumber)
Brings website up to date with main (18 commits): H1a parse-error pedagogy, V5/H3/V5a show+help commands, ADR-0043 compound-PK FK, handoffs 58-59, and the GitHub->Gitea doc scrub (Cargo.toml repository, CLAUDE.md, ADR-0001 amendment, requirements). Conflict: docs/adr/README.md. main and website had each created an ADR-0042 (main: H1a parse-error pedagogy; website: public website & docs site). Renumbered the website ADR to 0044 (next free after main's 0042/0043) and updated all references (ADR file, plan file, STYLE.md, astro.config.mjs, README index). Website build verified green.
This commit is contained in:
@@ -73,8 +73,11 @@ Current decisions at a glance (each backed by an ADR):
|
|||||||
- **Sharing:** `export` command produces a zip without the `.db`;
|
- **Sharing:** `export` command produces a zip without the `.db`;
|
||||||
no hosted publishing (ADR-0007).
|
no hosted publishing (ADR-0007).
|
||||||
- **Testing:** four-tier strategy from `cargo test` units up to
|
- **Testing:** four-tier strategy from `cargo test` units up to
|
||||||
PTY-based end-to-end (ADR-0008). Tiers 1–3 are active; Tier 4
|
PTY-based end-to-end (ADR-0008). Tiers 1–3 are active; **Tier 4
|
||||||
is wired only for the listed critical flows.
|
is not yet wired** — ADR-0008 specifies the PTY harness and the
|
||||||
|
four critical flows, but no PTY deps or tests exist yet
|
||||||
|
(verified 2026-06-07; corrects an earlier "wired only for the
|
||||||
|
listed critical flows" claim). Tracked as `requirements.md` TT4.
|
||||||
- **DSL syntax conventions:** required clauses use keyword
|
- **DSL syntax conventions:** required clauses use keyword
|
||||||
grammar (`with pk`, `to table` optional, `from..to`, `set`,
|
grammar (`with pk`, `to table` optional, `from..to`, `set`,
|
||||||
`where`); `--` flags are reserved for opt-in choices; one
|
`where`); `--` flags are reserved for opt-in choices; one
|
||||||
@@ -163,6 +166,17 @@ Key invariants in the code:
|
|||||||
until it settles. The ADR-0000 index-upkeep rule applies:
|
until it settles. The ADR-0000 index-upkeep rule applies:
|
||||||
every ADR change updates `docs/adr/README.md` in the same
|
every ADR change updates `docs/adr/README.md` in the same
|
||||||
edit.
|
edit.
|
||||||
|
- **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
|
||||||
|
for **scope and decisions**; issues are the lightweight tracker
|
||||||
|
for **discrete work items**, cross-referenced from commits and
|
||||||
|
handoffs (e.g. `fix: … (#12)`). The project is near completion
|
||||||
|
of its initial requirements, so no heavyweight planning workflow
|
||||||
|
is run — the document-based requirements are augmented with
|
||||||
|
issue references as work proceeds. A change that touches a
|
||||||
|
*decided* area still earns an ADR; the issue references the ADR,
|
||||||
|
it does not replace it.
|
||||||
- **Testing.** Per the user's global standards, tests are
|
- **Testing.** Per the user's global standards, tests are
|
||||||
established before changes, bugs are reproduced with failing
|
established before changes, bugs are reproduced with failing
|
||||||
tests before being fixed, and "all green, no skips" is the
|
tests before being fixed, and "all green, no skips" is the
|
||||||
@@ -187,6 +201,64 @@ Key invariants in the code:
|
|||||||
`git commit` is preceded by an explicit message proposal
|
`git commit` is preceded by an explicit message proposal
|
||||||
and user approval. No AI attribution in commit messages.
|
and user approval. No AI attribution in commit messages.
|
||||||
|
|
||||||
|
## Issue tracking — Gitea via `tea`
|
||||||
|
|
||||||
|
Extends (does not replace) the generic Gitea/`tea` safety rules in
|
||||||
|
the global `CLAUDE.md`. Use `tea` to manage Gitea issues; `tea
|
||||||
|
--help`, `tea issues --help`, etc. for command reference.
|
||||||
|
|
||||||
|
**Repo coordinates.** This repo lives on the self-hosted Gitea at
|
||||||
|
`git.lazyeval.net` as `oli/rdbms-playground`. `tea` **auto-detects
|
||||||
|
it correctly off the git remote** — verified — so plain `tea issues`
|
||||||
|
works here even though the machine's *default* `tea` login is a
|
||||||
|
different host (`git.oliversturm.com`). Pass `--login
|
||||||
|
git.lazyeval.net --repo oli/rdbms-playground` only as a fallback if
|
||||||
|
auto-detection ever slips. **Never** fall back to raw API calls
|
||||||
|
(`curl`/`fetch`) when `tea` misbehaves — tokens leak into shell
|
||||||
|
history; fix `tea` instead (usually `--login`/`--repo`).
|
||||||
|
|
||||||
|
**Labels.** Preconfigured (`bug`, `enhancement` are in use).
|
||||||
|
**Ask the user before creating new labels.** Create with `tea
|
||||||
|
labels create --name <n> --color <hex> --description <d>`.
|
||||||
|
|
||||||
|
### Critical gotchas
|
||||||
|
|
||||||
|
- **`tea` blocks on stdin in a non-TTY → hangs.** `tea comment`,
|
||||||
|
`tea issue … --comments`, and similar **wait on stdin** when not
|
||||||
|
attached to a terminal, so they hang silently. **Always append
|
||||||
|
`< /dev/null`**, and wrap in `timeout 30` as a safety net:
|
||||||
|
`timeout 30 tea comment <idx> "$body" < /dev/null`. Verify the
|
||||||
|
write landed afterwards (re-fetch); don't trust a clean exit alone.
|
||||||
|
- **Multi-line comment / description bodies**: heredocs do **not**
|
||||||
|
work with `--description` / the comment-body arg. Write the
|
||||||
|
markdown to a temp file and pass it via shell substitution: `tea
|
||||||
|
comment <idx> "$(cat /tmp/body.md)" < /dev/null` (same for `tea
|
||||||
|
issues edit --description "$(cat …)"`).
|
||||||
|
- **Read an issue's RAW body** (for editing): the default/`--output
|
||||||
|
yaml` view is a lossy rendered box. Use JSON: `tea issue <idx>
|
||||||
|
--fields body --output json < /dev/null | jq -r '.body'`. **`tea
|
||||||
|
issues edit --description` replaces the WHOLE body** — splice
|
||||||
|
surgically and keep the raw backup before applying.
|
||||||
|
- **Reopen**: use `tea issues reopen <index>`, NOT `tea issues edit
|
||||||
|
--state open`.
|
||||||
|
- **Milestones** (not currently used here, but if introduced): set
|
||||||
|
with `tea issues edit --milestone "<name>" <idx>` (empty string
|
||||||
|
clears it). **Options MUST precede the `<idx>`** — flag-after-index
|
||||||
|
silently no-ops. The `tea issues create --milestone …` flag is
|
||||||
|
**unreliable** — set the milestone with a follow-up `edit` and
|
||||||
|
verify.
|
||||||
|
- **Display blind-spot — don't loop on this.** `tea issue <n>
|
||||||
|
--fields milestone` and `--fields comments` render `None`/`0`
|
||||||
|
**even when set** — they are NOT a source of truth. Confirm a
|
||||||
|
**milestone** via the filtered list (`tea issues list --milestones
|
||||||
|
"<name>" --limit 100 | grep <idx>`; presence = set); confirm a
|
||||||
|
**posted comment** via `tea issue <n> --comments` (NOT the
|
||||||
|
`comments` count field). Labels/state/title DO render correctly on
|
||||||
|
the single-issue fetch; only milestone + comments don't.
|
||||||
|
- **Pagination**: default ~50 issues. Use `--limit 100` (or more)
|
||||||
|
for full lists; `--state all` to include closed; `--output
|
||||||
|
tsv`/`json` for parseable output.
|
||||||
|
|
||||||
## Build hygiene
|
## Build hygiene
|
||||||
|
|
||||||
`target/` is git-ignored and 100% regenerable, but it grows
|
`target/` is git-ignored and 100% regenerable, but it grows
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A cross-platform TUI playground for learning relational databases."
|
description = "A cross-platform TUI playground for learning relational databases."
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/sturm/rdbms-playground"
|
repository = "https://git.lazyeval.net/oli/rdbms-playground"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -45,3 +45,25 @@ package managers (`cargo binstall`, Homebrew, Scoop, `winget`).
|
|||||||
- TUI styling will require explicit work to match the polish that
|
- TUI styling will require explicit work to match the polish that
|
||||||
Bubble Tea / Lipgloss give for free; budget for it in the design
|
Bubble Tea / Lipgloss give for free; budget for it in the design
|
||||||
pass.
|
pass.
|
||||||
|
|
||||||
|
## Amendment 1 — Distribution channel is open after the Gitea migration (2026-06-09)
|
||||||
|
|
||||||
|
The *Decision* block above assumed prebuilt binaries would ship "via
|
||||||
|
GitHub releases plus package managers." Since then the repository has
|
||||||
|
been migrated off GitHub to a self-hosted Gitea instance
|
||||||
|
(`git.lazyeval.net/oli/rdbms-playground`), and `tea` is the forge CLI
|
||||||
|
in use. The "GitHub releases" half of that sentence is therefore no
|
||||||
|
longer a settled assumption.
|
||||||
|
|
||||||
|
This amendment does **not** pick a replacement. Binary distribution is
|
||||||
|
not built yet (no release pipeline, no CI — `requirements.md` TT5/E*
|
||||||
|
remain open), so the channel for prebuilt binaries is an **open
|
||||||
|
choice** — Gitea releases, a GitHub mirror's releases, or both — to be
|
||||||
|
settled by a dedicated ADR when distribution is actually implemented.
|
||||||
|
The package-manager channels named in the Decision (`cargo binstall`,
|
||||||
|
Homebrew, Scoop, `winget`) are independent of the forge and are
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
(For the same supersede-don't-rewrite reason, the Decision block also
|
||||||
|
still names `sqlparser-rs`, which ADRs 0030–0036 replaced with a
|
||||||
|
hand-rolled grammar; that is recorded there, not by editing this ADR.)
|
||||||
|
|||||||
@@ -2,7 +2,29 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted.
|
**Superseded by ADR-0024** (2026-05-14). Accepted then superseded
|
||||||
|
without being implemented.
|
||||||
|
|
||||||
|
> **Superseding note (2026-06-03).** This ADR was never built. It
|
||||||
|
> specifies a `chumsky`-over-tokens architecture — a separate lexer
|
||||||
|
> producing `Vec<Token>`, a `define_keywords!` macro, and chumsky
|
||||||
|
> grammar combinators consuming `&[Token]`. ADR-0024 (unified grammar
|
||||||
|
> tree) instead adopted a **scannerless hand-rolled walker** that
|
||||||
|
> operates directly on source bytes, and **removed chumsky from the
|
||||||
|
> project entirely** (it is no longer a dependency). The lexer,
|
||||||
|
> `keyword.rs`, and the token model described below do not exist.
|
||||||
|
>
|
||||||
|
> What this ADR got *right* survives in ADR-0024: the
|
||||||
|
> expected-set aggregation it wanted (one branch's report no longer
|
||||||
|
> swallowing the others) is delivered by the walker's structural
|
||||||
|
> `expected` derivation, and the I3 (completion) / I4 (highlighting)
|
||||||
|
> hooks it anticipated are served by the same walker. Read ADR-0024
|
||||||
|
> for the architecture as built; this ADR remains as institutional
|
||||||
|
> memory of the path not taken and the reasoning that led there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Original status (historical):* Accepted.
|
||||||
|
|
||||||
Amends ADR-0001 (language and TUI framework) by adding a
|
Amends ADR-0001 (language and TUI framework) by adding a
|
||||||
tokenization layer between the source string and the chumsky
|
tokenization layer between the source string and the chumsky
|
||||||
|
|||||||
@@ -2,7 +2,37 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted.
|
**Mechanism superseded by ADR-0024; H1a scope continued in ADR-0042.**
|
||||||
|
Accepted then superseded.
|
||||||
|
|
||||||
|
> **Superseding note (2026-06-03).** The *intent* of this ADR — surface
|
||||||
|
> the grammar of the command at the point of error, not just the next
|
||||||
|
> token — survived and is largely delivered. The *mechanism* did not.
|
||||||
|
> This ADR specifies a `chumsky`-based design: a separate `UsageEntry`
|
||||||
|
> registry in `src/dsl/usage.rs`, `parse.token.*` catalog keys driven
|
||||||
|
> by chumsky's `RichPattern<Token>` expected sets, and a renderer over
|
||||||
|
> chumsky output. ADR-0024 (unified grammar tree) replaced chumsky with
|
||||||
|
> a scannerless walker and **folded usage info onto the grammar nodes
|
||||||
|
> themselves**: `usage_ids` live on each `CommandNode`, the per-command
|
||||||
|
> `parse.usage.*` templates and the `parse.available_commands` fallback
|
||||||
|
> ship as designed here, and the expected-set vocabulary
|
||||||
|
> (`format_expectation` in `parser.rs`) renders directly from walker
|
||||||
|
> `Expectation` variants — no `UsageEntry` registry, no `parse.token.*`
|
||||||
|
> keys, no `src/dsl/usage.rs`.
|
||||||
|
>
|
||||||
|
> So: the §1 usage registry, §3 "deepest consumed keyword" mechanism,
|
||||||
|
> §4 `parse.token.*` catalog, and §7 validator details below describe
|
||||||
|
> code that does not exist. What shipped equivalently: §1's per-command
|
||||||
|
> templates (as `usage_ids` + `parse.usage.*`), §2's three-block render
|
||||||
|
> (echo+caret / structural error / usage), and §5's available-commands
|
||||||
|
> fallback. **ADR-0042 picks up H1a from here** — it records what is
|
||||||
|
> actually shipped and defines the remaining systematic-pass scope
|
||||||
|
> against the grammar-tree architecture. Read ADR-0042 for the live
|
||||||
|
> plan; this ADR remains as the design rationale for the pedagogy goal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Original status (historical):* Accepted.
|
||||||
|
|
||||||
Builds on ADR-0020 (tokenization layer). Addresses H1a from
|
Builds on ADR-0020 (tokenization layer). Addresses H1a from
|
||||||
`requirements.md` — the parse-error pedagogy gap that
|
`requirements.md` — the parse-error pedagogy gap that
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
# ADR-0042: H1a parse-error pedagogy in the grammar-tree era
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted** — 2026-06-03.
|
||||||
|
|
||||||
|
Continues H1a (`requirements.md`) from **ADR-0021**, whose
|
||||||
|
chumsky-based mechanism was superseded by **ADR-0024** (unified
|
||||||
|
grammar tree). ADR-0021's *intent* — surface the grammar of the
|
||||||
|
command at the point of error, not just the next token — is
|
||||||
|
re-stated here against the architecture as actually built, with
|
||||||
|
an inventory of what already ships and a definition of done for
|
||||||
|
the remaining work.
|
||||||
|
|
||||||
|
Cross-references ADR-0019 (friendly-error layer + i18n catalog
|
||||||
|
conventions; H1a output shares the catalog), ADR-0022 (ambient
|
||||||
|
typing assistance, which shares the walker's expected-set
|
||||||
|
machinery), ADR-0024 (the grammar tree), and ADR-0009 (DSL
|
||||||
|
surface conventions; usage templates render in the documented
|
||||||
|
surface form).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Why a new ADR rather than amending ADR-0021
|
||||||
|
|
||||||
|
ADR-0021 specifies a `UsageEntry` registry in `src/dsl/usage.rs`,
|
||||||
|
`parse.token.*` catalog keys, and a renderer over chumsky's
|
||||||
|
`RichPattern<Token>` expected sets. None of that exists. ADR-0024
|
||||||
|
removed chumsky from the project, deleted `usage.rs`, and folded
|
||||||
|
usage information onto the grammar nodes themselves. Amending
|
||||||
|
ADR-0021 in place would force every reader to mentally translate a
|
||||||
|
dead mechanism; a fresh ADR records the live state directly.
|
||||||
|
ADR-0020 and ADR-0021 keep their superseding notes and remain as
|
||||||
|
institutional memory.
|
||||||
|
|
||||||
|
### What H1a is
|
||||||
|
|
||||||
|
When a learner types something near-correct, the error should
|
||||||
|
*name the missing keyword or clause* and *show the shape of the
|
||||||
|
command*, rather than point a caret at the unexpected character.
|
||||||
|
The user-reported gap: typing `create` once produced
|
||||||
|
`parse error: after \`create\`, expected \`table\`` — structurally
|
||||||
|
true, pedagogically silent.
|
||||||
|
|
||||||
|
### What already ships (the baseline — do not re-build)
|
||||||
|
|
||||||
|
Verified against code on 2026-06-03. The grammar-tree migration
|
||||||
|
delivered most of ADR-0021's intent through different machinery:
|
||||||
|
|
||||||
|
1. **Per-command usage block.** Every `CommandNode` carries
|
||||||
|
`usage_ids: &'static [&'static str]`
|
||||||
|
(`src/dsl/grammar/mod.rs`). On any parse error the renderer
|
||||||
|
emits a `usage:` block listing every form of the matched
|
||||||
|
command family — 38 templates under `parse.usage.*`
|
||||||
|
(`src/friendly/strings/en-US.yaml:499-571`), resolved by
|
||||||
|
`grammar::usage_keys_for_input` and rendered by
|
||||||
|
`render_usage_block` (`src/app.rs:2560`).
|
||||||
|
|
||||||
|
2. **Available-commands fallback.** When no command keyword was
|
||||||
|
consumed, the block becomes
|
||||||
|
`available commands: …` (`parse.available_commands`,
|
||||||
|
`en-US.yaml:493`; `app.rs:2593`).
|
||||||
|
|
||||||
|
3. **Structural error names the consumed prefix and expected
|
||||||
|
set.** `format_walker_error` (`src/dsl/parser.rs:289`) renders
|
||||||
|
`after \`<consumed>\`, expected <set>, found <token|end of
|
||||||
|
input>`, distinguishing incomplete-at-EOF (`at_eof = true`,
|
||||||
|
more input would help) from a definite mid-input mismatch.
|
||||||
|
|
||||||
|
4. **Friendly slot labels for identifiers.** `format_expectation`
|
||||||
|
(`src/dsl/parser.rs:262`) renders `Ident` slots by source —
|
||||||
|
"table name", "column name", "relationship name", "index
|
||||||
|
name", "type" — instead of a bare "identifier" (ADR-0022 stage
|
||||||
|
8c).
|
||||||
|
|
||||||
|
5. **Curated custom messages** for high-value near-misses under
|
||||||
|
`parse.custom.*` (`en-US.yaml:443-478`): `create_table_needs_pk`,
|
||||||
|
`insert_form_a_missing_values` ("looks like Form A — add
|
||||||
|
`values (...)`"), `change_column_flags_exclusive`,
|
||||||
|
`bind_type_mismatch`, the redundant-constraint and
|
||||||
|
alter-add-primary-key cases, etc.
|
||||||
|
|
||||||
|
6. **Schema-aware pre-flight diagnostics** that light the
|
||||||
|
`[ERR]` validity indicator *at typing time* (ADR-0027 /
|
||||||
|
ADR-0033 / ADR-0036): INSERT arity for Forms A/B/C, unknown
|
||||||
|
table/column, type mismatch, `= NULL`, NOT-NULL-missing, and —
|
||||||
|
on the advanced-SQL surface — `cte_arity_mismatch`,
|
||||||
|
`compound_arity_mismatch`, and `projection_alias_misplaced`
|
||||||
|
(`diagnostic.*`, `en-US.yaml:577-620`; walker logic in
|
||||||
|
`src/dsl/walker/mod.rs`).
|
||||||
|
|
||||||
|
7. **Ambient "Next:" hints** and the **simple→advanced cross-mode
|
||||||
|
pointer** (ADR-0022 / `advanced_alternative_note`,
|
||||||
|
`src/input_render.rs`).
|
||||||
|
|
||||||
|
So H1a is *substantially* delivered at the intent level. The
|
||||||
|
handoff's two canonical examples already behave: `insert into T
|
||||||
|
('Oli')` → custom Form-A message; `update T set x=1` → structural
|
||||||
|
"expected `where` or `--all-rows`" + usage block.
|
||||||
|
|
||||||
|
### What remains — the genuine gap
|
||||||
|
|
||||||
|
The remaining work is **systematic verification plus targeted
|
||||||
|
polish**, not a missing feature:
|
||||||
|
|
||||||
|
- **No enumerated coverage guarantee.** Coverage is curated
|
||||||
|
case-by-case; nothing asserts that *every required slot in every
|
||||||
|
command* produces a pedagogically-sound near-miss message.
|
||||||
|
- **Literal expectations render terse.** `Word`/`Literal`/`Punct`/
|
||||||
|
`Flag` slots come out as backticked literals (`` `where` ``,
|
||||||
|
`` `=` ``, `` `--all-rows` ``). Correct, but a learner is helped
|
||||||
|
more by a short prose gloss in select high-value positions.
|
||||||
|
- **Advanced-mode SQL parse pedagogy is thinner** than the DSL
|
||||||
|
surface (RETURNING scope, CTE-arity diagnostic positioning,
|
||||||
|
`CROSS JOIN … ON`, INSERT…SELECT column-count). No other ADR or
|
||||||
|
open issue covers this (ADR-0019 §OOS-2 covers advanced-SQL
|
||||||
|
*engine-error sanitisation* — a different layer).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Definition of done — a verified near-miss matrix
|
||||||
|
|
||||||
|
H1a is "done" when there is a test matrix that, for **every
|
||||||
|
command in the REGISTRY**, exercises its salient near-miss inputs
|
||||||
|
and asserts the rendered output reads pedagogically. "Salient
|
||||||
|
near-misses" per command means at minimum:
|
||||||
|
|
||||||
|
- the bare entry keyword alone (`create`, `add`, `update`);
|
||||||
|
- each required clause omitted (e.g. `update T set x=1` with no
|
||||||
|
filter rail; `insert into T (cols)` with no `values`);
|
||||||
|
- a wrong token where a specific slot is expected (e.g. a number
|
||||||
|
where a table name belongs);
|
||||||
|
- the zero-prefix / unknown-command case (available-commands
|
||||||
|
fallback).
|
||||||
|
|
||||||
|
The matrix lives in the existing surfaces — `tests/typing_surface/`
|
||||||
|
(snapshot-based, the standalone `typing_surface_matrix` binary) for
|
||||||
|
the typing-time hint/validity view, and
|
||||||
|
`tests/it/parse_error_pedagogy.rs` (the consolidated `it` binary)
|
||||||
|
for the submit-time rendered three-block output. New integration
|
||||||
|
tests go in `tests/it/` per the handoff-57 §3 layout rule — **not**
|
||||||
|
as new top-level `tests/*.rs`.
|
||||||
|
|
||||||
|
Work is **test-first**: add the matrix entry, observe the current
|
||||||
|
rendering, and only then adjust wording/labels where it reads
|
||||||
|
poorly. A near-miss whose current rendering is already good is
|
||||||
|
locked by a snapshot, not rewritten.
|
||||||
|
|
||||||
|
### 2. Friendlier literal expectation labels
|
||||||
|
|
||||||
|
`format_expectation` gains, for high-value keyword/punct positions,
|
||||||
|
an optional prose gloss while **always keeping the exact literal
|
||||||
|
visible** — a learner must still see the precise token to type.
|
||||||
|
The principle: a label may *add* role context, never *replace* the
|
||||||
|
literal.
|
||||||
|
|
||||||
|
Illustrative target (final wording settled per-case against the
|
||||||
|
matrix, as is normal for pedagogical text):
|
||||||
|
|
||||||
|
- `expected \`where\` or \`--all-rows\`` →
|
||||||
|
`expected a filter clause: \`where …\` or \`--all-rows\``
|
||||||
|
- `expected \`values\`` (after a Form-A column list) →
|
||||||
|
already covered by `parse.custom.insert_form_a_missing_values`;
|
||||||
|
the matrix confirms it fires.
|
||||||
|
|
||||||
|
Mechanism (illustrative, finalised at implementation time): a
|
||||||
|
grammar `Word`/`Punct` node may carry an optional expectation-label
|
||||||
|
key, mirroring how `Ident` slots derive a label from
|
||||||
|
`IdentSource`. Absent an override, rendering is unchanged (the
|
||||||
|
backticked literal). This keeps the change additive and per-slot —
|
||||||
|
no blanket reword that would churn the anchor-phrase tests
|
||||||
|
needlessly.
|
||||||
|
|
||||||
|
New glosses are catalog-sourced (`parse.expect.*` or reuse of
|
||||||
|
`parse.usage.*` fragments — chosen at implementation time) so
|
||||||
|
wording stays in `en-US.yaml`, not in code, consistent with
|
||||||
|
ADR-0019.
|
||||||
|
|
||||||
|
### 3. Advanced-mode SQL parse pedagogy — in scope
|
||||||
|
|
||||||
|
The same matrix discipline (§1) extends to the advanced-mode SQL
|
||||||
|
surface. Two of the relevant arity diagnostics **already exist** and
|
||||||
|
must not be re-built — `cte_arity_mismatch` and
|
||||||
|
`compound_arity_mismatch` (`en-US.yaml:590-591`); for these the work
|
||||||
|
is matrix coverage and, for CTE, auditing whether the diagnostic is
|
||||||
|
*positioned at the CTE name* (easiest to fix) rather than the body.
|
||||||
|
The remaining items were re-checked empirically at implementation
|
||||||
|
time (2026-06-05) and **most turned out already handled** — see the
|
||||||
|
Implementation-outcome section's advanced-SQL paragraph for the
|
||||||
|
corrected picture. The `:` one-shot escape (a simple-mode line run
|
||||||
|
once in advanced mode) is part of the advanced surface and is
|
||||||
|
covered by the mode-aware usage threading (G3).
|
||||||
|
|
||||||
|
This stays clear of ADR-0019 §OOS-2 (advanced-SQL *engine-error*
|
||||||
|
sanitisation): §OOS-2 reworks errors raised by *executing* SQL;
|
||||||
|
H1a here concerns errors raised while *parsing* it. If a near-miss
|
||||||
|
turns out to be an engine error rather than a parse error, it is
|
||||||
|
out of H1a scope and noted against §OOS-2 instead.
|
||||||
|
|
||||||
|
### 4. Catalog and anchor-phrase discipline
|
||||||
|
|
||||||
|
All new or reworded user-facing strings go through the i18n catalog
|
||||||
|
(`en-US.yaml`) and the `KEYS_AND_PLACEHOLDERS` validator, per
|
||||||
|
ADR-0019. No engine vocabulary in any string (CLAUDE.md).
|
||||||
|
|
||||||
|
Two anchor styles constrain §2's glosses and both are preserved by
|
||||||
|
its "literal always visible" rule:
|
||||||
|
|
||||||
|
- The **substring assertions** in `src/dsl/parser.rs` tests
|
||||||
|
("after `…`", "expected table name", "found end of input",
|
||||||
|
"unknown type", "expected one of").
|
||||||
|
- The **substring assertions** in `tests/it/parse_error_pedagogy.rs`,
|
||||||
|
which check for backticked literals and usage fragments
|
||||||
|
(e.g. `` `column` ``, `` `1` ``, "create table", "with pk"). This
|
||||||
|
test is `.contains()`-based, not snapshot-based, so a §2 gloss
|
||||||
|
that dropped the bare literal would fail it — which is precisely
|
||||||
|
the regression §2's rule prevents.
|
||||||
|
|
||||||
|
The snapshot-based `tests/typing_surface/` matrix will re-baseline
|
||||||
|
on any §2 wording change (expected; reviewed via `cargo insta`),
|
||||||
|
but the two substring suites above must stay green without edits to
|
||||||
|
their assertions.
|
||||||
|
|
||||||
|
## Implementation outcome (2026-06-05)
|
||||||
|
|
||||||
|
The baseline capture (§Implementation notes step 1) triaged four
|
||||||
|
gaps; all four are fixed test-first, locked by the near-miss matrix
|
||||||
|
in `tests/it/parse_error_pedagogy.rs`:
|
||||||
|
|
||||||
|
- **G1** — the bare `1` cardinality literal opening `add 1:n
|
||||||
|
relationship …` rendered cryptically. Render it as
|
||||||
|
`` `1:n relationship` `` in `format_expectation` (error wording
|
||||||
|
only; completion still offers the literal `1`).
|
||||||
|
- **G2** — bare `select` dumped the 14-item expression first-set.
|
||||||
|
Collapse it to "a projection: `*`, a column, or an expression"
|
||||||
|
in `format_walker_error`, detected by the `distinct`+`all`
|
||||||
|
quantifier pair being *jointly* expectable — a signature unique
|
||||||
|
to a projection start (empirically verified not to misfire at
|
||||||
|
`count(`, `union`, `union all`, `select distinct`, or mid-list).
|
||||||
|
Render-only; the completion/hint layer still expands the full
|
||||||
|
set.
|
||||||
|
- **G3** — the usage block was mode-blind (`render_usage_block`
|
||||||
|
resolved shared entry words to the first-registered Simple node).
|
||||||
|
`usage_key(s)_for_input` gain mode-aware `_in_mode` variants.
|
||||||
|
|
||||||
|
**Decision (user-confirmed, after the DA pass).** In advanced
|
||||||
|
mode the DSL forms remain *valid input* via fallback — verified:
|
||||||
|
`create table Foo with pk`, `drop column from table T: c`,
|
||||||
|
`drop relationship r`, `add column …` all parse and dispatch in
|
||||||
|
advanced mode. So the advanced usage block shows **every form
|
||||||
|
valid in the mode, mode-primary (SQL) first, then the DSL
|
||||||
|
fallback forms** — a usage hint must never hide input that works.
|
||||||
|
(An initial implementation that showed SQL-only was flagged by
|
||||||
|
the DA pass as hiding `create table … with pk` / `drop column …`
|
||||||
|
and corrected.) Simple mode shows DSL forms only — the SQL-only
|
||||||
|
forms hit the "this is SQL" rail and are unreachable.
|
||||||
|
|
||||||
|
- **G4** — `with` borrowed `select`'s usage; it gains its own
|
||||||
|
`parse.usage.with` CTE template.
|
||||||
|
|
||||||
|
**Advanced-SQL pedagogy (§3) — empirical re-check (2026-06-05).**
|
||||||
|
§3 (drafted from a code survey) listed `RETURNING` scope,
|
||||||
|
`CROSS JOIN … ON`, and INSERT…SELECT column-count as absences.
|
||||||
|
Verifying each against the running app **reversed two of three**:
|
||||||
|
|
||||||
|
- **INSERT…SELECT column-count** is *already handled* — a count
|
||||||
|
mismatch fires `verdict = Error` with "the column list names N
|
||||||
|
column(s) but M value(s) are given" (walker test
|
||||||
|
`insert_select_arity_mismatch_fires`). It is a structural
|
||||||
|
list-vs-list check, so it fires even without a schema. Not a gap.
|
||||||
|
*Caveat (pre-existing, not addressed here):* a `SELECT *`
|
||||||
|
projection is not expanded for arity, so `insert into T (one_col)
|
||||||
|
select * from Multi` is not pre-caught — the engine rejects it at
|
||||||
|
execution. Star-expansion for pre-flight arity would be a separate
|
||||||
|
enhancement (and brushes ADR-0019 §OOS-2 engine-error territory).
|
||||||
|
- **RETURNING scope** is *already handled* — at a bare `returning`
|
||||||
|
position completion offers the table's columns; `returning
|
||||||
|
<unknown>` fires the `unknown_column` diagnostic. Not a gap.
|
||||||
|
- **`CROSS JOIN … ON`** *was* a genuine residual: the grammar
|
||||||
|
rejects the `on` but the structural error said only "expected end
|
||||||
|
of input". **Fixed** — `parse.cross_join_no_on` renders "a CROSS
|
||||||
|
JOIN has no ON clause — …" when the failing token is `on` and the
|
||||||
|
most recent consumed join is a CROSS join (a precise signature:
|
||||||
|
every other join requires `on`, so there `on` is expected, not a
|
||||||
|
failure). Render-only, no grammar change; two misfire guards
|
||||||
|
(plain join still asks for `on`; a stray `on` with no join does
|
||||||
|
not fire). The CTE/compound arity diagnostics noted above remain
|
||||||
|
present and correct.
|
||||||
|
|
||||||
|
**Known low-priority residual (user-confirmed to defer).** At
|
||||||
|
*submit* time, an incomplete expression position that is not a
|
||||||
|
SELECT projection (bare `where `, `returning `, `having `, `set
|
||||||
|
col=`) still renders the raw ~14-item expression first-set; only the
|
||||||
|
SELECT projection is glossed (G2, keyed on the `distinct`+`all`
|
||||||
|
quantifier pair). This is low-impact because *typing*-time
|
||||||
|
completion already offers the correct candidates (columns,
|
||||||
|
functions, expression keywords) at those positions. Generalising the
|
||||||
|
gloss was considered and deferred — the payoff is small and a
|
||||||
|
broader render-side collapse adds misfire surface.
|
||||||
|
|
||||||
|
Coverage: the matrix covers, in both modes, every entry word's bare
|
||||||
|
/ missing-clause / wrong-token near-misses, the app-lifecycle
|
||||||
|
trailing-junk cases, **and** the committed *multi-form* variants
|
||||||
|
(`add index` / `add constraint` / `add 1:n relationship`, `drop
|
||||||
|
index` / `drop constraint` / `drop relationship`, `show table`,
|
||||||
|
`change column …`, `create index`, `alter table … add` / `… drop`).
|
||||||
|
The committed forms were audited 2026-06-05 and each renders its own
|
||||||
|
form-specific missing-keyword message + usage (e.g. `add index` →
|
||||||
|
"expected `on` or `as`"; `drop constraint` → "expected `not`,
|
||||||
|
`unique`, `default`, or `check`"), regression-locked in
|
||||||
|
`near_miss_matrix_committed_multiforms`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
1. **Advanced-SQL engine-error sanitisation** — ADR-0019 §OOS-2.
|
||||||
|
2. **Tab completion (I3) and syntax highlighting (I4)** as
|
||||||
|
features — they share the walker but are separate ADRs.
|
||||||
|
3. **Schema-aware "did you mean `Customers`?" spell-correction** —
|
||||||
|
ADR-0021's out-of-scope §2; belongs with I3.
|
||||||
|
4. **Multi-error reporting.** The walker reports the first error
|
||||||
|
and stops; unchanged.
|
||||||
|
5. **`messages`-style verbosity gating of the usage block.** Per
|
||||||
|
ADR-0021 §8 the usage block is always shown; parse errors are
|
||||||
|
exactly when pedagogical surface should be maximal. Unchanged.
|
||||||
|
6. **Auto-generating usage/help text from the grammar.** ADR-0024
|
||||||
|
left help prose hand-curated; templates stay hand-written.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- H1a gains an explicit, enumerated definition of done instead of
|
||||||
|
an open-ended "systematic pass still pending".
|
||||||
|
- The matrix becomes a regression lock: future grammar changes
|
||||||
|
that degrade a near-miss message fail a snapshot.
|
||||||
|
- Literal-label glosses close the last terse-wording gap without a
|
||||||
|
blanket reword.
|
||||||
|
- The advanced-SQL surface reaches parity with the DSL surface for
|
||||||
|
the audience that has switched to raw SQL.
|
||||||
|
|
||||||
|
### Costs
|
||||||
|
|
||||||
|
- Wording iteration across many near-miss cases — but cheap,
|
||||||
|
catalog-driven, and snapshot-guarded.
|
||||||
|
- The §2 per-node label field is one more annotation a new command
|
||||||
|
may set (optional; default unchanged).
|
||||||
|
- Snapshot volume grows; acceptable given the existing ~160-entry
|
||||||
|
typing-surface matrix.
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- No public API change. `parse_command*` signatures, the
|
||||||
|
`ParseError` shape, and the three-block render path are all
|
||||||
|
unchanged; this ADR adds wording, labels, and tests within them.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
Order of operations (test-first throughout):
|
||||||
|
|
||||||
|
1. Enumerate the per-command near-miss matrix (§1) as failing/asserting
|
||||||
|
tests in `tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`.
|
||||||
|
Capture current rendering as the starting baseline.
|
||||||
|
2. Triage: which entries read poorly? Only those get wording work.
|
||||||
|
3. Add the optional expectation-label mechanism (§2) and apply it
|
||||||
|
to the high-value keyword/punct positions surfaced in triage.
|
||||||
|
4. Advanced-SQL near-miss audit + fixes (§3), distinguishing parse
|
||||||
|
from engine errors as they arise.
|
||||||
|
5. Catalog validator + anchor-phrase checks stay green (§4).
|
||||||
|
6. Update `requirements.md` H1a with the matrix as the done-marker;
|
||||||
|
flip to `[x]` only when the matrix is complete and green.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- ADR-0021 — Parser-as-source-of-truth for H1a (mechanism
|
||||||
|
superseded; intent continued here).
|
||||||
|
- ADR-0020 — Tokenization layer (superseded by the scannerless
|
||||||
|
walker).
|
||||||
|
- ADR-0024 — Unified grammar tree (the architecture H1a is built
|
||||||
|
on).
|
||||||
|
- ADR-0022 — Ambient typing assistance (shares the expected-set
|
||||||
|
machinery).
|
||||||
|
- ADR-0019 — Friendly-error layer and i18n catalog (§OOS-2 is the
|
||||||
|
adjacent engine-error scope).
|
||||||
|
- ADR-0009 — DSL command-syntax conventions (usage surface form).
|
||||||
|
- `requirements.md` — H1a tracking entry.
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# ADR-0043: Compound-primary-key foreign-key references (T3)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted + implemented** — 2026-06-09. Implementation landed the
|
||||||
|
same day: the relationship model went list-based through all six
|
||||||
|
layers (refactor commit `b14f019`, single-column preserved), then
|
||||||
|
the DSL + SQL grammars gained multi-column parsing and the
|
||||||
|
executor the full-PK/auto-expand/per-pair-type-compat/auto-name/
|
||||||
|
`--create-fk`-per-column logic. Verified by 12 integration tests in
|
||||||
|
`tests/it/compound_fk.rs` (parse both surfaces, engine-enforced FK,
|
||||||
|
arity + partial-PK refusal, `--create-fk`, single-column
|
||||||
|
preserved) on top of the existing single-column relationship
|
||||||
|
suite. `requirements.md` **T3** is `[x]`. All four genuine forks
|
||||||
|
confirmed by the user at the recommended option: **F-A** full PK in order, **F-B**
|
||||||
|
house-style uniform column lists (no migration; back-compat not
|
||||||
|
required), **F-C** parenthesized DSL lists, **F-D** bare table-level
|
||||||
|
SQL FK auto-expands to the parent's full PK. Closes the one open
|
||||||
|
leg of
|
||||||
|
`requirements.md` **T3** ("compound primary keys handled
|
||||||
|
end-to-end (DSL, storage, display, **FK reference**)"): a foreign
|
||||||
|
key that *references* a compound (multi-column) primary key.
|
||||||
|
|
||||||
|
Cross-references **ADR-0011** (FK column type compatibility —
|
||||||
|
`Type::fk_target_type`), **ADR-0013** (relationships, naming, the
|
||||||
|
rebuild-table strategy, and the `__rdbms_playground_relationships`
|
||||||
|
metadata table), **ADR-0035 §4b** (the SQL `FOREIGN KEY` surface),
|
||||||
|
**ADR-0004 / ADR-0015** (`project.yaml` as the authoritative
|
||||||
|
format; `playground.db` is a derived artifact), and **ADR-0009**
|
||||||
|
(DSL surface conventions).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Compound PRIMARY KEYs are declared, stored, and displayed today
|
||||||
|
(`create table T with pk a(int), b(int)` → `primary_key:
|
||||||
|
Vec<String>`). The missing leg is the *reference*: a child table
|
||||||
|
whose foreign key points at a parent's compound PK. A 2026-06-09
|
||||||
|
codebase audit found single-column FK is a pervasive assumption —
|
||||||
|
~15–20 sites across 6+ files:
|
||||||
|
|
||||||
|
- **Metadata** — `__rdbms_playground_relationships` stores scalar
|
||||||
|
`parent_column TEXT` / `child_column TEXT`
|
||||||
|
(`PRIMARY KEY (child_table, child_column)`).
|
||||||
|
- **Persistence** — `RelationshipSchema { parent_column: String,
|
||||||
|
child_column: String }`; `project.yaml` `RawEndpoint { table,
|
||||||
|
column }`.
|
||||||
|
- **Grammar** — `add 1:n relationship … from <P>.<col> to
|
||||||
|
<C>.<col>` (one ident per side); SQL `FOREIGN KEY (<col>)
|
||||||
|
REFERENCES <P>(<col>)` (parens that hold exactly one ident).
|
||||||
|
- **AST** — `Command::AddRelationship { parent_column: String,
|
||||||
|
child_column: String }`; `SqlForeignKey { child_column: String,
|
||||||
|
parent_column: Option<String> }`.
|
||||||
|
- **Executor** — `schema_to_ddl` emits a single-column
|
||||||
|
`FOREIGN KEY (c) REFERENCES P(p)`; `check_fk_type_compat`
|
||||||
|
compares one parent type to one child type; bare
|
||||||
|
`REFERENCES <P>` on a compound-PK parent is refused as
|
||||||
|
ambiguous (`resolve_create_table_fks`,
|
||||||
|
`do_alter_add_foreign_key`).
|
||||||
|
- **Display** — `RelationshipEnd { other_column: String,
|
||||||
|
local_column: String }`.
|
||||||
|
|
||||||
|
This is not a sweep-sized change, which is why it earns an ADR
|
||||||
|
rather than an inline build. The decisions below also turn the
|
||||||
|
audit's worst-case framing (a metadata-schema + yaml-format
|
||||||
|
migration via the F3 framework) into a **no-migration** change.
|
||||||
|
|
||||||
|
### Why no migration is needed
|
||||||
|
|
||||||
|
**Decision input (user, 2026-06-09): back-compatibility with
|
||||||
|
existing saved projects is not required.** The project is
|
||||||
|
pre-release; there is no installed base of `project.yaml` /
|
||||||
|
`playground.db` files to preserve. This removes the only force
|
||||||
|
that would have demanded an F3 migrator or a version bump, and —
|
||||||
|
more importantly — it lets the representation be chosen for
|
||||||
|
*cleanliness and consistency* rather than for byte-identical
|
||||||
|
back-compat. The consequence is explicit and accepted: a
|
||||||
|
`project.yaml` written before this change that contains
|
||||||
|
relationships will not load under the new format.
|
||||||
|
|
||||||
|
Freed of back-compat, the storage follows the convention the file
|
||||||
|
**already uses** for ordered column lists rather than inventing a
|
||||||
|
new one:
|
||||||
|
|
||||||
|
- `project.yaml` already writes `primary_key: [id]` (a compound PK
|
||||||
|
is `primary_key: [a, b]`) and index `columns: [a, b]`
|
||||||
|
(`RawIndex { columns: Vec<String> }`). The relationship endpoint
|
||||||
|
is the lone multi-column-capable slot still using a scalar
|
||||||
|
`column:`. It joins the house style (D5).
|
||||||
|
- The metadata columns are `TEXT`; SQLite has no array type, so a
|
||||||
|
list lives in a text cell as JSON regardless. That JSON is now a
|
||||||
|
*uniform* encoding (a one-element array for the single-column
|
||||||
|
case), not a "bare-name-or-JSON, sniff which" fallback — the
|
||||||
|
fallback only existed to keep old rows identical, which is no
|
||||||
|
longer a goal.
|
||||||
|
|
||||||
|
So this is not a clever back-compat dodge; it is "use the existing
|
||||||
|
list convention, uniformly." No version bump, no F3 migrator.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Support a foreign key that references a parent's **full** compound
|
||||||
|
primary key, matched **positionally** to an equal-length child
|
||||||
|
column list, with per-pair type compatibility — across both the
|
||||||
|
DSL and SQL surfaces — using format-flexible storage that needs no
|
||||||
|
migration.
|
||||||
|
|
||||||
|
### D1 — Matching policy: the full PK, in order
|
||||||
|
|
||||||
|
A compound-PK FK references **all** columns of the parent's
|
||||||
|
primary key, in PK declaration order, matched 1:1 to the child's
|
||||||
|
column list (same length). Referencing a *subset* of a compound PK
|
||||||
|
is **out of scope**: SQL/SQLite require FK parent columns to form a
|
||||||
|
PK or UNIQUE key, and a strict subset of a compound PK is not
|
||||||
|
itself unique unless separately constrained. Teaching-clean rule:
|
||||||
|
*a foreign key to a compound key names every column of that key.*
|
||||||
|
|
||||||
|
A length mismatch (child supplies N columns, parent PK has M ≠ N)
|
||||||
|
is a friendly error naming both counts.
|
||||||
|
|
||||||
|
### D2 — Type compatibility: per pair, positional
|
||||||
|
|
||||||
|
Each child column's type must satisfy
|
||||||
|
`parent_pk_col.fk_target_type() == child_col` for the
|
||||||
|
corresponding pair (the existing ADR-0011 rule, applied
|
||||||
|
element-wise in order). `check_fk_type_compat` generalises to walk
|
||||||
|
the pairs and report the **first** offending pair with the same
|
||||||
|
wording it uses today.
|
||||||
|
|
||||||
|
### D3 — DSL syntax: parenthesized column lists
|
||||||
|
|
||||||
|
`add 1:n relationship [as <name>]
|
||||||
|
from <P>.(<a>, <b>) to <C>.(<x>, <y>)
|
||||||
|
[on delete …] [on update …] [--create-fk]`
|
||||||
|
|
||||||
|
The single-column form `from <P>.<col> to <C>.<col>` is unchanged
|
||||||
|
(no parens) — back-compatible and the common case. The
|
||||||
|
parenthesized list is the multi-column form. Both sides must use
|
||||||
|
the same arity (enforced as a D1 length check). Parentheses mirror
|
||||||
|
the existing compound-PK *declaration* syntax (`with pk a(int),
|
||||||
|
b(int)` uses parens around the per-column type; the FK list uses
|
||||||
|
parens around the column names) and the SQL `FOREIGN KEY (…)`
|
||||||
|
shape, so the surface stays internally consistent.
|
||||||
|
|
||||||
|
### D4 — SQL syntax: extend the existing lists
|
||||||
|
|
||||||
|
`FOREIGN KEY (<x>, <y>) REFERENCES <P> (<a>, <b>)` — the grammar's
|
||||||
|
child and parent column slots become comma-separated **lists**
|
||||||
|
(today capped at one). Inline `<col> <type> REFERENCES <P>(<a>,
|
||||||
|
<b>)` stays single-child-column (one inline column can't match a
|
||||||
|
2-column key) — a compound FK uses the table-level form. Bare
|
||||||
|
table-level `FOREIGN KEY (x, y) REFERENCES <P>` (no parent
|
||||||
|
columns) **auto-expands to the parent's full PK** when the arities
|
||||||
|
match; bare inline `<col> REFERENCES <P>` on a compound-PK parent
|
||||||
|
keeps today's friendly refusal, with the message pointing at the
|
||||||
|
table-level multi-column form.
|
||||||
|
|
||||||
|
### D5 — Storage: uniform column lists, matching the house style
|
||||||
|
|
||||||
|
Both stores hold an **ordered column list**, uniformly (a
|
||||||
|
one-element list for the single-column case), following the
|
||||||
|
convention `project.yaml` already uses for `primary_key` and index
|
||||||
|
`columns`.
|
||||||
|
|
||||||
|
- **`project.yaml`**: `RawEndpoint` becomes `{ table, columns:
|
||||||
|
Vec<String> }` and writes `columns: [a, b]` (single-column →
|
||||||
|
`columns: [id]`), exactly parallel to `primary_key: [id]`. No
|
||||||
|
scalar `column:` form, no dual-shape reader.
|
||||||
|
- **Metadata** (`__rdbms_playground_relationships`): no
|
||||||
|
`CREATE TABLE` change (the `TEXT` columns and
|
||||||
|
`PRIMARY KEY (child_table, child_column)` are untouched).
|
||||||
|
`parent_column` / `child_column` store the list **comma-joined**
|
||||||
|
in the same text cell (`a,b`; a single column is just its bare
|
||||||
|
name). *As-built note:* the ADR first said "JSON array"; the
|
||||||
|
implementation uses a comma delimiter, which is safe because
|
||||||
|
column identifiers are `[A-Za-z0-9_]+` (no commas — `parser.rs`)
|
||||||
|
and simpler (no `serde_json` dependency). This is an internal
|
||||||
|
encoding detail below fork F-B — the user-visible `project.yaml`
|
||||||
|
is still the `columns: [a, b]` list.
|
||||||
|
The actual enforced FK lives on the rebuilt child table's DDL
|
||||||
|
(`FOREIGN KEY (a, b) REFERENCES P(x, y)`), emitted by
|
||||||
|
`schema_to_ddl`, exactly as the single-column FK is today via the
|
||||||
|
rebuild-table primitive (ADR-0013) — one relationship, one undo
|
||||||
|
step.
|
||||||
|
|
||||||
|
### D6 — In-memory model: `Vec<String>` column lists
|
||||||
|
|
||||||
|
`Command::AddRelationship`, `SqlForeignKey`, `RelationshipSchema`,
|
||||||
|
the internal `ReadForeignKey`, and `RelationshipEnd` (display) all
|
||||||
|
carry `parent_columns: Vec<String>` / `child_columns: Vec<String>`
|
||||||
|
(or `Option<Vec<String>>` for the bare-SQL parent case). A
|
||||||
|
one-element vec is the single-column case; nothing about the
|
||||||
|
single-column UX changes.
|
||||||
|
|
||||||
|
## Genuine forks (escalated for sign-off)
|
||||||
|
|
||||||
|
These are decisions, not facts. Recommendations are marked; the
|
||||||
|
user confirms before this ADR moves to Accepted.
|
||||||
|
|
||||||
|
- **F-A — matching policy.** Full PK only (D1, *recommended*) vs.
|
||||||
|
allow a subset (needs a separate UNIQUE key; larger, less
|
||||||
|
teaching-clean).
|
||||||
|
- **F-B — storage encoding.** Uniform column lists in the existing
|
||||||
|
house style — `columns: [a, b]` in yaml (like `primary_key`),
|
||||||
|
JSON-array in the unchanged metadata `TEXT` columns; no
|
||||||
|
back-compat, no migration (D5, *recommended*) vs. a normalized
|
||||||
|
relationship-columns child table (more "correct" but a schema
|
||||||
|
change with joins on read, no learner-visible payoff). Premise:
|
||||||
|
no existing projects to preserve (confirmed).
|
||||||
|
- **F-C — DSL multi-column syntax.** `from P.(a, b) to C.(x, y)`
|
||||||
|
parenthesized (D3, *recommended*) vs. a repeated-dotted form
|
||||||
|
(`from P.a, P.b to C.x, C.y`, more ambiguous to parse and read).
|
||||||
|
- **F-D — bare table-level SQL FK auto-expansion.** Auto-expand
|
||||||
|
`FOREIGN KEY (x,y) REFERENCES P` to P's full PK when arities
|
||||||
|
match (D4, *recommended*) vs. always require explicit parent
|
||||||
|
columns.
|
||||||
|
|
||||||
|
## Implementation sketch (change sites)
|
||||||
|
|
||||||
|
Grouped; each lands behind tests. No migration step.
|
||||||
|
|
||||||
|
1. **AST** — `AddRelationship` + `SqlForeignKey` column fields →
|
||||||
|
`Vec<String>` / `Option<Vec<String>>` (`command.rs`).
|
||||||
|
2. **Grammar** — DSL endpoint column slot → optional
|
||||||
|
parenthesized list (`ddl.rs`); SQL child/parent column slots →
|
||||||
|
comma lists (`sql_create_table.rs`). Builders collect lists.
|
||||||
|
3. **Metadata** — `insert_relationship_metadata` /
|
||||||
|
`read_all_relationships` encode/decode bare-or-JSON
|
||||||
|
(`db.rs`); no `CREATE TABLE` change.
|
||||||
|
4. **Persistence** — `RelationshipSchema` → `Vec<String>`;
|
||||||
|
`RawEndpoint` becomes `{ table, columns: Vec<String> }`, written
|
||||||
|
`columns: [a, b]` like `primary_key`
|
||||||
|
(`persistence/mod.rs`, `persistence/yaml.rs`).
|
||||||
|
5. **Executor** — `do_add_relationship` /
|
||||||
|
`resolve_create_table_fks` / `do_alter_add_foreign_key` walk
|
||||||
|
column lists; `schema_to_ddl` emits multi-column `FOREIGN KEY
|
||||||
|
(…) REFERENCES P(…)`; `check_fk_type_compat` loops pairs;
|
||||||
|
bare-reference paths auto-expand to the full PK (D4) or refuse
|
||||||
|
with the improved message; the default relationship-name
|
||||||
|
generator (`db.rs:6850`) joins the column lists; `--create-fk`
|
||||||
|
creates one child column per parent PK column (`db.rs`).
|
||||||
|
6. **Display** — `RelationshipEnd` → column lists; `describe`
|
||||||
|
renders `(a, b) → (x, y)` symmetrically (outbound + inbound,
|
||||||
|
ADR-0013) (`db.rs`, `output_render.rs`).
|
||||||
|
7. **Teaching echo (ADR-0038)** — `render_add_relationship` and
|
||||||
|
`render_add_relationship_create_fk` (`echo.rs`) go multi-column:
|
||||||
|
the FK line emits `FOREIGN KEY (a, b) REFERENCES P (x, y)`, and
|
||||||
|
`--create-fk` emits **one `ADD COLUMN` line per newly-created
|
||||||
|
child column** (each typed to the matching parent PK column's
|
||||||
|
`fk_target_type`) before the FK line. Copy-paste contract
|
||||||
|
(ADR-0038) holds: every echoed line is runnable advanced SQL.
|
||||||
|
8. **Tests** — parse (DSL + SQL: single-col still works; multi
|
||||||
|
parses; arity mismatch errors; empty `()` rejected; inline
|
||||||
|
`col REFERENCES P(a,b)` rejected with the table-level pointer);
|
||||||
|
worker round-trip (declare a 2-col FK, rebuild, the FK is
|
||||||
|
**enforced** — an insert violating it is refused; per-pair
|
||||||
|
type-mismatch refused; bare-FK **auto-expand** to the parent PK;
|
||||||
|
`--create-fk` creates both child columns); persistence
|
||||||
|
round-trip (a single-col relationship writes `columns: [id]` and
|
||||||
|
reads back; a 2-col writes `columns: [a, b]` and reads back;
|
||||||
|
full save→rebuild reconstructs the FK); **undo** (add a 2-col
|
||||||
|
relationship, undo, it is gone — one step); display
|
||||||
|
(`describe` shows `(a, b) → (x, y)` both directions).
|
||||||
|
|
||||||
|
## Implementation-readiness notes (DA pass, 2026-06-09)
|
||||||
|
|
||||||
|
Verified against the code before build; folded in so the plan is
|
||||||
|
complete.
|
||||||
|
|
||||||
|
- **SQLite precondition holds.** A FK's parent columns must be a
|
||||||
|
PK or a UNIQUE-indexed set. A SQLite `PRIMARY KEY (a, b)` creates
|
||||||
|
the requisite unique index, so `FOREIGN KEY (x, y) REFERENCES
|
||||||
|
P(a, b)` is valid against a compound PK with no extra index.
|
||||||
|
STRICT tables do not change FK rules. (F-A's "full PK" therefore
|
||||||
|
always targets a valid key; a subset would not be unique — the
|
||||||
|
reason F-A excludes it.)
|
||||||
|
- **Explicit parent columns must be exactly the PK set.** Under
|
||||||
|
F-A, `REFERENCES P(<cols>)` is accepted iff `<cols>` is the
|
||||||
|
parent's PK column **set**; any ordering is accepted and maps
|
||||||
|
positionally to the child list (SQLite matches the set to the
|
||||||
|
unique index; the child↔parent pairing is positional). A
|
||||||
|
non-PK, partial, or super-set list is refused with a friendly
|
||||||
|
message naming the parent's actual PK (subset/UNIQUE targets are
|
||||||
|
OOS).
|
||||||
|
- **Arity + emptiness.** Child and parent lists must be equal,
|
||||||
|
non-zero length; a mismatch reports both counts
|
||||||
|
("N child column(s) but M in `P`'s key"). An empty `()` list is
|
||||||
|
a parse error. Inline single-column `col REFERENCES P(a, b)` is
|
||||||
|
refused (one inline column can't satisfy a 2-column key) with a
|
||||||
|
pointer to the table-level `FOREIGN KEY (…)` form (D4).
|
||||||
|
- **DSL `from P.(a)` (single in parens)** is accepted — equivalent
|
||||||
|
to bare `from P.a` — so the parenthesized form is uniform across
|
||||||
|
arities; the bare form stays the idiomatic single-column
|
||||||
|
spelling.
|
||||||
|
- **`--create-fk` is per-column.** When child columns are missing,
|
||||||
|
one is created per parent PK column, each typed to that parent
|
||||||
|
column's `fk_target_type` (ADR-0011) — generalising today's
|
||||||
|
single-column behaviour; the echo mirrors this (sketch step 7).
|
||||||
|
- **Metadata identity unchanged.** `PRIMARY KEY (child_table,
|
||||||
|
child_column)` still holds with the JSON-array string as the
|
||||||
|
key — so a child column **set** still participates in at most one
|
||||||
|
relationship (pre-existing behaviour, now per-set). Distinct
|
||||||
|
sets on the same child table are distinct keys.
|
||||||
|
- **Auto-name generation** (`db.rs:6850`, the `[as <name>]`-less
|
||||||
|
default) is single-column today
|
||||||
|
(`{parent_table}_{parent_column}_to_{child_table}_{child_column}`)
|
||||||
|
— it must join the column lists (e.g.
|
||||||
|
`Orders_a_b_to_Customers_x_y`). A found change site the first
|
||||||
|
sketch missed; added to the executor step.
|
||||||
|
- **Undo / batch unchanged.** One `add 1:n relationship` is one
|
||||||
|
rebuild = one undo step (ADR-0013/0006), independent of arity.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- T3 closes; a learner can model a real composite-key relationship
|
||||||
|
end to end.
|
||||||
|
- No migration, and the on-disk representation gets *more*
|
||||||
|
consistent: the relationship endpoint joins the `primary_key:
|
||||||
|
[...]` / index `columns: [...]` list convention. The in-app
|
||||||
|
single-column UX is untouched (one-element vecs).
|
||||||
|
- Accepted trade-off (user, 2026-06-09): a `project.yaml` written
|
||||||
|
before this change that contains relationships will not load
|
||||||
|
under the new format. There is no installed base to preserve, so
|
||||||
|
this is a clean cutover, not data loss.
|
||||||
|
- The relationship model becomes list-based throughout, which is
|
||||||
|
the natural foundation if subset/UNIQUE-targeted FKs are ever
|
||||||
|
wanted (explicitly OOS here).
|
||||||
|
- A modest, broad refactor (the `Vec` field change ripples through
|
||||||
|
the 6 layers) — methodical, not deep; locked by tests at each
|
||||||
|
layer.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Subset/non-PK FK targets (referencing a UNIQUE key that isn't
|
||||||
|
the PK) — possible later on this list-based foundation.
|
||||||
|
- Any change to single-column behaviour, the rebuild-table
|
||||||
|
primitive, or the undo model (one relationship = one undo step
|
||||||
|
stands).
|
||||||
|
- A `project.yaml` version bump or F3 migrator (not needed —
|
||||||
|
no installed base to migrate; clean cutover per D5).
|
||||||
+7
-2
@@ -1,9 +1,14 @@
|
|||||||
# ADR-0042: Public website and documentation site
|
# ADR-0044: Public website and documentation site
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted (2026-06-04). Implementation plan:
|
Accepted (2026-06-04). Implementation plan:
|
||||||
[`docs/plans/20260604-adr-0042-website.md`](../plans/20260604-adr-0042-website.md).
|
[`docs/plans/20260604-adr-0044-website.md`](../plans/20260604-adr-0044-website.md).
|
||||||
|
|
||||||
|
> Renumbered from ADR-0042 to ADR-0044 when the `website` branch merged
|
||||||
|
> `main` (2026-06-09): `main` had independently used 0042 for the H1a
|
||||||
|
> parse-error ADR and 0043 for compound-PK FK references. Content is
|
||||||
|
> unchanged from the original draft.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
+6
-4
@@ -6,7 +6,7 @@ This directory contains the project's ADRs, recorded per
|
|||||||
## Index
|
## Index
|
||||||
|
|
||||||
- [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md)
|
- [ADR-0000 — Record architecture decisions](0000-record-architecture-decisions.md)
|
||||||
- [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.md)
|
- [ADR-0001 — Language and TUI framework](0001-language-and-tui-framework.md) — **Amendment 1 (2026-06-09)**: after the GitHub→Gitea migration (`git.lazyeval.net`), the prebuilt-binary distribution channel named in the Decision ("GitHub releases") is reopened as an undecided choice, to be settled by a future distribution ADR; package-manager channels unaffected
|
||||||
- [ADR-0002 — Database engine](0002-database-engine.md)
|
- [ADR-0002 — Database engine](0002-database-engine.md)
|
||||||
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14). The app-command registry gains **`copy`** (ADR-0041, issue #11)
|
- [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14). The app-command registry gains **`copy`** (ADR-0041, issue #11)
|
||||||
- [ADR-0004 — Project file format](0004-project-file-format.md)
|
- [ADR-0004 — Project file format](0004-project-file-format.md)
|
||||||
@@ -25,8 +25,8 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0017 — Column type-change compatibility](0017-column-type-change-compatibility.md)
|
- [ADR-0017 — Column type-change compatibility](0017-column-type-change-compatibility.md)
|
||||||
- [ADR-0018 — Auto-fill contracts for `serial` and `shortid` columns](0018-auto-fill-contracts-for-serial-and-shortid.md)
|
- [ADR-0018 — Auto-fill contracts for `serial` and `shortid` columns](0018-auto-fill-contracts-for-serial-and-shortid.md)
|
||||||
- [ADR-0019 — Friendly error layer (H1) and i18n message catalog](0019-friendly-error-layer-and-i18n.md)
|
- [ADR-0019 — Friendly error layer (H1) and i18n message catalog](0019-friendly-error-layer-and-i18n.md)
|
||||||
- [ADR-0020 — Tokenization layer for the DSL parser](0020-tokenization-layer-for-the-dsl-parser.md)
|
- [ADR-0020 — Tokenization layer for the DSL parser](0020-tokenization-layer-for-the-dsl-parser.md) — **Superseded by ADR-0024 (never implemented).** Specified a `chumsky`-over-tokens architecture (separate lexer, `define_keywords!`, `&[Token]` grammar). ADR-0024 adopted a scannerless hand-rolled walker and removed `chumsky` entirely; the lexer/keyword/token model here does not exist. Kept as institutional memory of the path not taken.
|
||||||
- [ADR-0021 — Parser-as-source-of-truth for H1a (per-command usage in parse errors)](0021-parser-as-source-of-truth-for-h1a.md)
|
- [ADR-0021 — Parser-as-source-of-truth for H1a (per-command usage in parse errors)](0021-parser-as-source-of-truth-for-h1a.md) — **Mechanism superseded by ADR-0024; H1a scope continued in ADR-0042.** The *intent* (show the command's grammar at the point of error) shipped — `usage_ids` on each `CommandNode`, the `parse.usage.*` templates, and the `available_commands` fallback all exist — but via grammar nodes, not the `chumsky` `UsageEntry` registry / `parse.token.*` keys described here (which were never built).
|
||||||
- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor
|
- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor
|
||||||
- [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024)
|
- [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024)
|
||||||
- [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note)
|
- [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note)
|
||||||
@@ -47,4 +47,6 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0039 — EXPLAIN over advanced-mode SQL queries](0039-explain-over-advanced-sql.md) — **Accepted** (2026-05-27), **implemented 2026-05-30 (issue #7)**, **supersedes ADR-0030 §13 OOS-2**. Lets `explain` wrap the advanced SQL commands (`Select`/`SqlInsert`/`SqlUpdate`/`SqlDelete`, plus `with`/CTE which builds a `Select`) in addition to the DSL `ShowData`/`Update`/`Delete` it already covers (ADR-0028), running `EXPLAIN QUERY PLAN` over the validated SQL text through the existing ADR-0028 span-styled plan tree (advanced mode only; DSL `explain` unchanged in both modes). Implemented via a second `Advanced` `explain` CommandNode (`EXPLAIN_SQL`) registered under the shared `explain` entry word — reusing the established `insert`/`update`/`delete` shared-word dispatch (`decide`: SQL-first / DSL-fallback), so `explain show data …` and DSL-only `--all-rows` still reach the DSL node; rejected a `DynamicSubgrammar` mode-gate (its resolution cache key omits `mode`). `build_explain_sql` slices the inner SQL off the source (excludes `explain`) and reuses the existing SQL builders; `do_explain_plan` runs the carried text verbatim, no params. Advanced `explain update`/`delete` now route through SQL (identical plan, full SQL syntax); DSL-explain tests pinned to simple mode. Reframed OOS-2 as a *deferred* exclusion (per ADR-0000's out-of-scope discipline), not a rejection. OOS (deferred): EXPLAIN of DDL (no query plan exists)
|
- [ADR-0039 — EXPLAIN over advanced-mode SQL queries](0039-explain-over-advanced-sql.md) — **Accepted** (2026-05-27), **implemented 2026-05-30 (issue #7)**, **supersedes ADR-0030 §13 OOS-2**. Lets `explain` wrap the advanced SQL commands (`Select`/`SqlInsert`/`SqlUpdate`/`SqlDelete`, plus `with`/CTE which builds a `Select`) in addition to the DSL `ShowData`/`Update`/`Delete` it already covers (ADR-0028), running `EXPLAIN QUERY PLAN` over the validated SQL text through the existing ADR-0028 span-styled plan tree (advanced mode only; DSL `explain` unchanged in both modes). Implemented via a second `Advanced` `explain` CommandNode (`EXPLAIN_SQL`) registered under the shared `explain` entry word — reusing the established `insert`/`update`/`delete` shared-word dispatch (`decide`: SQL-first / DSL-fallback), so `explain show data …` and DSL-only `--all-rows` still reach the DSL node; rejected a `DynamicSubgrammar` mode-gate (its resolution cache key omits `mode`). `build_explain_sql` slices the inner SQL off the source (excludes `explain`) and reuses the existing SQL builders; `do_explain_plan` runs the carried text verbatim, no params. Advanced `explain update`/`delete` now route through SQL (identical plan, full SQL syntax); DSL-explain tests pinned to simple mode. Reframed OOS-2 as a *deferred* exclusion (per ADR-0000's out-of-scope discipline), not a rejection. OOS (deferred): EXPLAIN of DDL (no query plan exists)
|
||||||
- [ADR-0040 — A per-command completion marker (✓/✗) replaces the `[ok]` summary line](0040-completion-marker-replaces-ok-summary.md) — **Accepted 2026-05-30 (issue #9)**, amends ADR-0014 / ADR-0028 / ADR-0019 output conventions, builds on ADR-0037's mode-tagged echo. An audit of the whole command surface found the `[ok] <verb> <subject>` summary line duplicates the echo line above it (verb+subject) everywhere; its only unique contribution is the success-vs-error signal (and `explain select` even rendered `[ok] explain` with an empty subject post-ADR-0039). Decision: drop the `[ok]` line and the symmetric `"…" failed:` prefix; the echo line gains a trailing inline **✓** (green, success) / **✗** (red, failure) — `running:` becomes a pending state that resolves to `<input> ✓/✗` on completion (status set via the existing `rfind(Echo)` lookup). Content (row counts, structure, data, plan tree, teaching echo) unchanged. Scoped to the DSL/data/SQL family that has the redundant echo+`[ok]` pair; app-command `[ok]` lines (`rebuild`/`export`/`now editing`) are payload-bearing, have no echo to mark, and stay as-is. `ok.summary` retired; `dsl.failed` reduced to the rendered reason. Broad but mechanical snapshot churn. OOS: app-command `[ok]` lines, the `[WRN]` validity indicator, and the tag colours (issue #10)
|
- [ADR-0040 — A per-command completion marker (✓/✗) replaces the `[ok]` summary line](0040-completion-marker-replaces-ok-summary.md) — **Accepted 2026-05-30 (issue #9)**, amends ADR-0014 / ADR-0028 / ADR-0019 output conventions, builds on ADR-0037's mode-tagged echo. An audit of the whole command surface found the `[ok] <verb> <subject>` summary line duplicates the echo line above it (verb+subject) everywhere; its only unique contribution is the success-vs-error signal (and `explain select` even rendered `[ok] explain` with an empty subject post-ADR-0039). Decision: drop the `[ok]` line and the symmetric `"…" failed:` prefix; the echo line gains a trailing inline **✓** (green, success) / **✗** (red, failure) — `running:` becomes a pending state that resolves to `<input> ✓/✗` on completion (status set via the existing `rfind(Echo)` lookup). Content (row counts, structure, data, plan tree, teaching echo) unchanged. Scoped to the DSL/data/SQL family that has the redundant echo+`[ok]` pair; app-command `[ok]` lines (`rebuild`/`export`/`now editing`) are payload-bearing, have no echo to mark, and stay as-is. `ok.summary` retired; `dsl.failed` reduced to the rendered reason. Broad but mechanical snapshot churn. OOS: app-command `[ok]` lines, the `[WRN]` validity indicator, and the tag colours (issue #10)
|
||||||
- [ADR-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough
|
- [ADR-0041 — Copy the output panel to the system clipboard](0041-copy-output-to-clipboard.md) — **Accepted 2026-06-02 (issue #11)**, amends ADR-0003's app-command registry (adds **`copy`** / `copy all` / `copy last`). The friction it removes: filing a bug report meant terminal-selecting the output panel and fighting wrapping/borders. New **app-level command** (sigil-free, both modes): `copy` / `copy all` copy the whole panel; `copy last` copies from the most recent echo line to the end. **Mechanism — OSC 52 *and* native (`arboard`), always both**, because OSC 52 acceptance is undetectable (no terminal ack), so a true "fall back when unsupported" can't be built: emit the OSC 52 escape (no new dep — `base64`+`crossterm`; works over SSH; tmux-passthrough-wrapped via `$TMUX`), then a best-effort native write whose failure is ignored (headless host — OSC 52 carried it); the two carry identical content. **Format — plain text verbatim as rendered** (tags, `✓`/`✗`, box-drawing) joined by `\n`, without viewport padding/wrapping; a drift-lock test pins `OutputLine::plain_text` to `render_output_line`. `arboard` added **`--no-default-features`** (drops the `image` crate; X11-only on Linux — `wayland-data-control` deliberately omitted as it ~doubles the dep tree and OSC 52 covers native-Wayland). Security: write-only, scans clean for arboard's tree (cargo audit / osv-scanner / grype), 1Password-maintained, minimal surface. OOS: Markdown export, selection/range, a keybinding, OSC 52 read, `screen` passthrough
|
||||||
- [ADR-0042 — Public website and documentation site](0042-public-website-and-documentation-site.md) — **Accepted 2026-06-04**. 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 (`dsl`/`app`/`ui`, in-memory `rusqlite` via `ffi-sqlite-wasm-rs`) vs native-edge (Tokio/worker-thread/`crossterm`/persistence/backup-API) boundary recorded for a future ADR + iteration plan. Portable **static build** (Vercel target, but host-agnostic); **no CI yet**; **monorepo** (`website/`). Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1–D3 track the release tooling). Plan: `docs/plans/20260604-adr-0042-website.md`
|
- [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block
|
||||||
|
- [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from <P>.(a, b) to <C>.(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec<String>`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change
|
||||||
|
- [ADR-0044 — Public website and documentation site](0044-public-website-and-documentation-site.md) — **Accepted 2026-06-04** (originally drafted as ADR-0042 on the `website` branch; renumbered on merge to avoid colliding with the H1a ADR-0042). 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 (`dsl`/`app`/`ui`, in-memory `rusqlite` via `ffi-sqlite-wasm-rs`) vs native-edge (Tokio/worker-thread/`crossterm`/persistence/backup-API) boundary recorded for a future ADR + iteration plan. Portable **static build** (Vercel target, but host-agnostic); **no CI yet**; **monorepo** (`website/`). Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1–D3 track the release tooling). Plan: `docs/plans/20260604-adr-0044-website.md`
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Session handoff — 2026-06-06 (58)
|
||||||
|
|
||||||
|
Fifty-eighth handover. Continues from handoff-57, whose **next job
|
||||||
|
was H1a**. This session did exactly that, end to end: **H1a
|
||||||
|
(strong syntax-help in parse errors) is now done and marked `[x]`**
|
||||||
|
in `requirements.md`, via a new ADR-0042 systematic pass. The arc
|
||||||
|
was audit → ADR → test-first matrix → four gap fixes → two
|
||||||
|
adversarial review passes that each caught a real defect. See §2.
|
||||||
|
|
||||||
|
## §1. State at handoff
|
||||||
|
|
||||||
|
**Branch:** `main`. **HEAD `c305dc7`.** **origin/main at `10f8c2a`**
|
||||||
|
(pushed mid-session) → **4 commits unpushed** (`649fdcb`, `1d4923b`,
|
||||||
|
`d6e229f`, `c305dc7`). Push is the user's step.
|
||||||
|
|
||||||
|
**Tests: 2158 passing / 0 failing / 1 ignored** (lib 1578, it 388,
|
||||||
|
typing_surface_matrix 192; the 1 ignored is the pre-existing one).
|
||||||
|
**Clippy clean** (nursery, all targets, `-D warnings`).
|
||||||
|
|
||||||
|
This session's commits (since handoff-57's `a8d0138`):
|
||||||
|
|
||||||
|
```
|
||||||
|
c305dc7 docs: mark H1a done via the ADR-0042 systematic pass
|
||||||
|
d6e229f feat: H1a CROSS JOIN ON teaching message; advanced-SQL gaps re-verified (ADR-0042)
|
||||||
|
1d4923b fix: H1a G3 advanced usage shows all valid forms; complete near-miss matrix (ADR-0042)
|
||||||
|
649fdcb feat: H1a parse-error gaps G2–G4 + advanced near-miss matrix (ADR-0042)
|
||||||
|
10f8c2a test: H1a near-miss matrix + friendlier `add 1:n relationship` label (ADR-0042)
|
||||||
|
0e6f767 docs: ADR-0042 — continue H1a parse-error pedagogy on the grammar tree
|
||||||
|
```
|
||||||
|
|
||||||
|
## §2. What happened this stretch — H1a, start to finish
|
||||||
|
|
||||||
|
### Audit first (the big correction)
|
||||||
|
|
||||||
|
The handoff-57 §6 pointer said "read ADR-0021 + ADR-0020". **Both
|
||||||
|
are obsolete.** They specify a `chumsky`-over-tokens mechanism
|
||||||
|
(`UsageEntry` registry, `parse.token.*` keys, a lexer) that
|
||||||
|
**ADR-0024 deleted** — chumsky is not a dependency; the parser is
|
||||||
|
the scannerless unified grammar-tree walker. ADR-0020/0021 are now
|
||||||
|
marked **Superseded** (status notes + README), kept as memory.
|
||||||
|
ADR-0021's *intent* survived and was already ~80% shipped via the
|
||||||
|
grammar tree. **Read `docs/adr/0042-h1a-parse-error-pedagogy-grammar-tree.md`,
|
||||||
|
not 0020/0021, for the live H1a design.**
|
||||||
|
|
||||||
|
### ADR-0042 + the user's three scope decisions
|
||||||
|
|
||||||
|
Wrote **ADR-0042** (continues H1a against the grammar tree). Three
|
||||||
|
forks escalated and decided by the user: (1) ADR hygiene =
|
||||||
|
superseded-notes + new ADR; (2) scope = matrix-verify + friendlier
|
||||||
|
literal labels; (3) advanced-SQL **in scope**.
|
||||||
|
|
||||||
|
### The near-miss matrix (the definition of done)
|
||||||
|
|
||||||
|
`tests/it/parse_error_pedagogy.rs` now holds a per-command
|
||||||
|
near-miss matrix, built **test-first** from an empirical baseline
|
||||||
|
capture. Covers, in both modes: every entry word's bare /
|
||||||
|
missing-clause / wrong-token cases, the app-lifecycle trailing-junk
|
||||||
|
cases, and the **committed multi-forms** (`add index`,
|
||||||
|
`add constraint`, `add 1:n relationship`, `drop index/constraint/
|
||||||
|
relationship`, `show table`, `change column`, `create index`,
|
||||||
|
`alter table add/drop`). Tests: `near_miss_matrix_simple_mode`,
|
||||||
|
`near_miss_matrix_advanced_mode`, `near_miss_matrix_committed_multiforms`,
|
||||||
|
plus per-gap tests.
|
||||||
|
|
||||||
|
### Four gap fixes (each test-first)
|
||||||
|
|
||||||
|
- **G1** — bare `1` cardinality literal → `` `1:n relationship` `` in
|
||||||
|
`format_expectation` (render-only; completion still offers `1`).
|
||||||
|
- **G2** — bare `select`'s 14-item expression first-set → "a
|
||||||
|
projection: `*`, a column, or an expression", detected by the
|
||||||
|
`distinct`+`all` quantifier pair (unique to a projection start;
|
||||||
|
empirically verified non-misfiring). Render-only in
|
||||||
|
`format_walker_error`.
|
||||||
|
- **G3** — usage block was mode-blind. Added `usage_*_in_mode`
|
||||||
|
(`src/dsl/grammar/mod.rs`) + mode threading through
|
||||||
|
`render_usage_block` (`app.rs`) and the ambient usage
|
||||||
|
(`input_render.rs`). **Decision (user, after review):** advanced
|
||||||
|
mode shows **all forms valid in the mode, SQL-first then the DSL
|
||||||
|
fallback forms** — DSL forms (`create table … with pk`,
|
||||||
|
`drop column …`) remain valid input in advanced mode (verified),
|
||||||
|
so a usage hint must not hide them. Simple mode = DSL only.
|
||||||
|
- **G4** — `with` got its own `parse.usage.with` CTE template
|
||||||
|
(was borrowing `select`'s).
|
||||||
|
- **CROSS JOIN ON** — `parse.cross_join_no_on` teaches "a CROSS JOIN
|
||||||
|
has no ON clause …" when the failing token is `on` and the most
|
||||||
|
recent consumed join is a CROSS join. `is_cross_join_on` in
|
||||||
|
`parser.rs`; render-only.
|
||||||
|
|
||||||
|
### Two adversarial review passes earned their keep
|
||||||
|
|
||||||
|
- Pass 1 caught **G3 over-correction**: an initial "SQL-only"
|
||||||
|
advanced usage block *hid* valid DSL fallback forms. Escalated →
|
||||||
|
user chose "show all valid forms" → fixed (`1d4923b`).
|
||||||
|
- "Verify, don't trust the survey" **reversed two of three**
|
||||||
|
advanced-SQL "gaps": INSERT…SELECT count and RETURNING scope were
|
||||||
|
*already handled* (the Explore-survey list was wrong, twice).
|
||||||
|
Only CROSS JOIN ON was real.
|
||||||
|
- The matrix itself caught a regression mid-work (advanced
|
||||||
|
insert/update/delete falling back to available-commands because
|
||||||
|
the SQL nodes have empty `usage_ids`; fixed with a union fallback).
|
||||||
|
|
||||||
|
### Deferred by decision (low-priority residual)
|
||||||
|
|
||||||
|
At **submit** time, a non-projection expression position (bare
|
||||||
|
`where `, `returning `, `having `, `set col=`) still renders the raw
|
||||||
|
~14-item expression first-set; only the SELECT projection is glossed
|
||||||
|
(G2). Low-impact because **typing-time completion already offers the
|
||||||
|
right candidates** there. User chose to leave it. Documented in
|
||||||
|
ADR-0042 + `requirements.md`.
|
||||||
|
|
||||||
|
Plus one **pre-existing caveat** (not this session's work, noted in
|
||||||
|
ADR-0042): `insert into T (one_col) select * from Multi` isn't
|
||||||
|
pre-caught for arity — `SELECT *` isn't expanded; the engine rejects
|
||||||
|
it at execution (brushes ADR-0019 §OOS-2 engine-error territory).
|
||||||
|
|
||||||
|
## §3. ⚠️ Where parse-error pedagogy lives now (read before touching)
|
||||||
|
|
||||||
|
- **Usage templates:** `parse.usage.*` in `src/friendly/strings/en-US.yaml`;
|
||||||
|
`usage_ids` on each `CommandNode` (`src/dsl/grammar/mod.rs`).
|
||||||
|
Mode-aware selection: `usage_keys_for_input_in_mode` /
|
||||||
|
`usage_key_for_input_in_mode`.
|
||||||
|
- **Structural error wording:** `format_walker_error` +
|
||||||
|
`format_expectation` in `src/dsl/parser.rs` (this is where the G1
|
||||||
|
label, G2 projection gloss, and CROSS JOIN message live —
|
||||||
|
render-only, they do **not** mutate the `Expectation` set the
|
||||||
|
completion/hint layer consumes).
|
||||||
|
- **Catalog discipline (ADR-0019):** every new key goes in
|
||||||
|
`en-US.yaml` **and** `src/friendly/keys.rs::KEYS_AND_PLACEHOLDERS`
|
||||||
|
(the `keys_validate_against_catalog` test enforces it).
|
||||||
|
- **Tests:** integration parse-error tests live in `tests/it/` per
|
||||||
|
the handoff-57 §3 rule — drop the file in `tests/it/` + add a
|
||||||
|
`mod` line to `tests/it/main.rs`. Schema-aware diagnostics are
|
||||||
|
tested at the walker level with a `SchemaCache` (`vschema` helper
|
||||||
|
in `tests/it/sql_insert.rs`).
|
||||||
|
|
||||||
|
## §4. Carried / unchanged
|
||||||
|
|
||||||
|
- **arboard decisions** (handoff-55 §3): X11-only on Linux; `copy`
|
||||||
|
reproduces `[system]` tags. One-line changes if revisited.
|
||||||
|
- No open GitHub issues (`gh issue list` empty; the project's issue
|
||||||
|
tracker is GitHub, not the gitea host `tea` is configured for).
|
||||||
|
|
||||||
|
## §5. Other tracks (from `requirements.md`)
|
||||||
|
|
||||||
|
Unchanged: Track 2 Iter 6 leftovers (history.log input-history
|
||||||
|
hydration polish, migration-framework exercise); C3a modify
|
||||||
|
relationship; C4 m:n convenience; **H1 done** (ADR-0019), **H1a
|
||||||
|
done** (ADR-0042); H2 `hint`; H3 `help` (partial — general
|
||||||
|
reference + `help <command>` still missing); V4 session-log /
|
||||||
|
Markdown export; I1/I1b multi-line + readline; I3 tab completion /
|
||||||
|
I4 syntax highlighting (the walker already exposes the hooks); TU1
|
||||||
|
tutorial (needs ADR); TT5 CI (not configured).
|
||||||
|
|
||||||
|
## §6. Next job — pick one
|
||||||
|
|
||||||
|
No single forced next step. Candidates, roughly by readiness:
|
||||||
|
|
||||||
|
1. **H3 `help` completion** — the grammar tree already iterates the
|
||||||
|
REGISTRY for the command list; the missing pieces are a general
|
||||||
|
reference and `help <command>` per-command detail. The
|
||||||
|
`help_id` per `CommandNode` is the hook. Small-to-medium.
|
||||||
|
2. **I3 tab completion** — the walker's expected-set + completion
|
||||||
|
candidates already exist (used by ambient hints); I3 is the
|
||||||
|
**UI/UX** (cursor handling, menu, accept). Needs its own ADR.
|
||||||
|
3. **The deferred H1a residual** (§2) — generalise the projection
|
||||||
|
gloss to other expression positions. Low value (completion
|
||||||
|
already covers typing-time); only if it bugs you.
|
||||||
|
4. **CI (TT5)** — test infra is solid (2158 green); no workflow yet.
|
||||||
|
|
||||||
|
## §7. How to take over
|
||||||
|
|
||||||
|
1. Read handoffs 56 → 57 → 58, then `CLAUDE.md`, then
|
||||||
|
`docs/requirements.md` (H1 and **H1a now `[x]`**),
|
||||||
|
`docs/adr/README.md`.
|
||||||
|
2. **For anything parse-error/pedagogy: read ADR-0042, not
|
||||||
|
ADR-0020/0021** (those are superseded; chumsky is gone).
|
||||||
|
3. Codebase on `main` at `c305dc7`, clean, 4 unpushed.
|
||||||
|
4. Process pins that paid off this arc, again: **audit before
|
||||||
|
assuming** (ADR-0021 was obsolete; H1a was mostly already
|
||||||
|
shipped), **verify empirically — don't trust the docs/survey**
|
||||||
|
(the advanced-SQL gap list was wrong twice; a regression hid in
|
||||||
|
"looks fine"), **escalate genuine forks** (the G3 all-forms
|
||||||
|
decision was the user's, not mine), and **test-first + matrix as
|
||||||
|
a regression lock** (it caught a regression I introduced).
|
||||||
|
Commits user-confirmed, append-only, no AI attribution.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Session handoff — 2026-06-09 (59)
|
||||||
|
|
||||||
|
Fifty-ninth handover. Continues from handoff-58 (H1a done). This
|
||||||
|
session did **three** distinct things, in order: (1) a
|
||||||
|
**tracking-integrity audit + reconciliation** of `requirements.md`
|
||||||
|
against the actual code; (2) a **partials sweep** closing V5, H3,
|
||||||
|
and a new V5a; (3) the big one — **T3 compound-PK foreign-key
|
||||||
|
references**, designed in **ADR-0043**, `/runda`-verified twice,
|
||||||
|
and implemented end-to-end across both surfaces.
|
||||||
|
|
||||||
|
## §1. State at handoff
|
||||||
|
|
||||||
|
**Branch:** `main`. **HEAD `4752ba2`.** 8 commits this session
|
||||||
|
(`28e7596` → `4752ba2`); push is the user's step.
|
||||||
|
|
||||||
|
**Tests: 2193 passing / 0 failing / 1 ignored** (lib 1578, it 423,
|
||||||
|
typing_surface_matrix 192; the 1 ignored is the long-standing
|
||||||
|
doc-test). **Clippy clean** (nursery, all targets).
|
||||||
|
|
||||||
|
**Requirements markers now:** `[ ]` 19, `[/]` 6, `[x]` 59,
|
||||||
|
`[~]` 11, `[-]` 3. (The `[/]` "partial / in progress" marker is
|
||||||
|
**new this session** — see §2.)
|
||||||
|
|
||||||
|
This session's commits:
|
||||||
|
```
|
||||||
|
4752ba2 feat: compound-PK foreign-key references — grammar + tests (ADR-0043)
|
||||||
|
b14f019 refactor: relationship model to column lists for compound FK (ADR-0043)
|
||||||
|
b688592 docs: ADR-0043 implementation-readiness notes from /runda DA pass
|
||||||
|
274e2b1 docs: ADR-0043 compound-PK foreign-key references (T3); accepted
|
||||||
|
1d898ad feat: V5a show relationship/index <name> detail views
|
||||||
|
757711f feat: H3 help <command> per-command detail + general reference
|
||||||
|
8dec784 feat: V5 show tables / relationships / indexes list commands
|
||||||
|
28e7596 docs: reconcile requirements tracking with verified code state
|
||||||
|
```
|
||||||
|
|
||||||
|
## §2. The tracking reconciliation (read this — it changes how you plan)
|
||||||
|
|
||||||
|
Planning off handoffs/`requirements.md` was **unreliable**: an
|
||||||
|
audit of all 35 `[ ]` items against the source found **~46 %
|
||||||
|
mis-marked**, overwhelmingly *under*-claimed (tab completion `I3`
|
||||||
|
and syntax highlighting `I4` were shipped but marked "not yet
|
||||||
|
implemented"). Root cause: the **binary legend** — a shipped
|
||||||
|
feature, a half-built one, and an untouched one all wore the same
|
||||||
|
`[ ]`.
|
||||||
|
|
||||||
|
Fix (commit `28e7596`): added a **`[/]` "partial / in progress"**
|
||||||
|
marker to the legend, with a dated reconciliation note. Re-marked:
|
||||||
|
- **7 shipped-but-`[ ]` → `[x]`:** S1, S4, S5, I1a, I3, I4, C1.
|
||||||
|
- **9 substantially-built → `[/]`** with explicit gap notes: S3,
|
||||||
|
A1, V1, V2, V5, T3, H3, DOC1, X1.
|
||||||
|
- Fixed a **false `CLAUDE.md` claim** ("Tier 4 is wired") — no PTY
|
||||||
|
tests exist; TT4 is spec-only.
|
||||||
|
|
||||||
|
**Discipline going forward:** move items `[ ]` → `[/]` → `[x]`;
|
||||||
|
keep the gap note current. Don't trust a marker without checking
|
||||||
|
the code — verify empirically.
|
||||||
|
|
||||||
|
## §3. The partials sweep (V5, H3, V5a)
|
||||||
|
|
||||||
|
Three small/medium, no-ADR partials closed (the others — V1, S3/V2,
|
||||||
|
A1, DOC1, X1 — need an ADR / output-model redesign / new features,
|
||||||
|
so were left `[/]`, see §5):
|
||||||
|
|
||||||
|
- **V5 `[x]`** (`8dec784`): `show tables` / `show relationships` /
|
||||||
|
`show indexes` as one `Command::ShowList { kind }`; read-only
|
||||||
|
worker `show_list`. 10 tests.
|
||||||
|
- **H3 `[x]`** (`757711f`): `help <command>` per-command detail
|
||||||
|
(HELP node takes an optional `BarePath` topic; `note_help_topic`
|
||||||
|
renders every command sharing the entry word) + `help types` +
|
||||||
|
friendly unknown-topic pointer. 9 tests.
|
||||||
|
- **V5a `[x]`** (`1d898ad`): singular `show relationship <name>` /
|
||||||
|
`show index <name>` detail — folded into `ShowList { kind, name:
|
||||||
|
Option<String> }`. Raised + closed this session (it was the
|
||||||
|
honest tracked home for V5's `[<name>]` clause).
|
||||||
|
|
||||||
|
## §4. T3 — compound-PK foreign-key references (the big one)
|
||||||
|
|
||||||
|
**ADR-0043** (`docs/adr/0043-compound-pk-foreign-key-references.md`,
|
||||||
|
Accepted + implemented). The open leg of T3: a FK that *references*
|
||||||
|
a parent's compound PK. The audit found single-column FK woven
|
||||||
|
through ~15–20 sites; it earned an ADR, not an inline build.
|
||||||
|
|
||||||
|
**Four forks, all user-confirmed at the recommended option:**
|
||||||
|
- **F-A** reference the parent's **full** PK (subset/UNIQUE-target
|
||||||
|
OOS), matched **positionally** to an equal-length child list.
|
||||||
|
- **F-B** **house-style uniform lists, no migration** — the
|
||||||
|
decisive simplifier: the user confirmed **back-compat is not
|
||||||
|
required** (pre-release, no installed base), so `project.yaml`
|
||||||
|
endpoints became `columns: [a, b]` (like `primary_key: [id]`),
|
||||||
|
metadata `TEXT` cells store the list **comma-joined** (bare for
|
||||||
|
single), and **no F3 migrator / version bump** was needed.
|
||||||
|
- **F-C** parenthesized DSL `from P.(a, b) to C.(x, y)` (single
|
||||||
|
bare form unchanged).
|
||||||
|
- **F-D** bare table-level SQL `FOREIGN KEY (x,y) REFERENCES P`
|
||||||
|
auto-expands to the parent's full PK when arities match.
|
||||||
|
|
||||||
|
**Implementation (two commits):**
|
||||||
|
- `b14f019` — **the refactor**: relationship model `String →
|
||||||
|
Vec<String>` through all six layers (`AddRelationship` /
|
||||||
|
`SqlForeignKey` AST, `RelationshipSchema`, metadata,
|
||||||
|
`project.yaml`, `ReadForeignKey`, `RelationshipEnd`). **Single-
|
||||||
|
column behaviour preserved** (one-element vecs); the existing
|
||||||
|
single-column relationship suite was the regression net. This is
|
||||||
|
the safe green checkpoint.
|
||||||
|
- `4752ba2` — **the grammar + tests**: multi-column parsing on both
|
||||||
|
surfaces (`AR_PARENT_COLS`/`AR_CHILD_COLS` Choice in `ddl.rs`;
|
||||||
|
`FK_*_COL_LIST` Repeated in `sql_create_table.rs`;
|
||||||
|
`consume_fk_reference` + the table-level/ALTER parsers collect
|
||||||
|
lists). Executor: `resolve_fk_parent_columns` (F-A validation +
|
||||||
|
F-D auto-expand + arity), per-pair `check_fk_type_compat`,
|
||||||
|
`schema_to_ddl` multi-column emission, `pragma_foreign_key_list`
|
||||||
|
read **grouped by `id`, ordered by `seq`** (positional pairing),
|
||||||
|
auto-name joins lists, `--create-fk` per-column, multi-column
|
||||||
|
teaching echo. 12 tests in `tests/it/compound_fk.rs`.
|
||||||
|
|
||||||
|
**`/runda` ran twice** (once on the ADR pre-build, once on the
|
||||||
|
finished code). The post-build pass **found 3 coverage gaps and
|
||||||
|
they were closed**: save→rebuild round-trip (the riskiest — proves
|
||||||
|
comma-encode/decode + yaml + FK reconstruction survive a text→db
|
||||||
|
rebuild), undo (one step), per-pair type-mismatch.
|
||||||
|
|
||||||
|
**Two residuals, confirmed non-blocking (noted in code):**
|
||||||
|
1. inline `col REFERENCES P(a,b)` gives a *generic* arity error,
|
||||||
|
not the ADR's tailored "use the table-level form" pointer.
|
||||||
|
2. a compound-FK *violation*'s friendly error names only the first
|
||||||
|
column pair (the ADR-0019 facts model is single-column).
|
||||||
|
Both are messaging-only; FK correctness/enforcement is unaffected.
|
||||||
|
|
||||||
|
**Process note (a real scare):** a broad regex script over test
|
||||||
|
files (to wrap `add_relationship` call args + struct fields in
|
||||||
|
`vec![]`) **over-reached into lib structs** it shouldn't have
|
||||||
|
(`RelationshipSelector::Endpoints`, `FailureContext`/
|
||||||
|
`TranslateContext` — all deliberately single-column), briefly
|
||||||
|
breaking the lib build. Recovered by reverting those specific
|
||||||
|
sites. Lesson: scripted edits across `src/` are dangerous when a
|
||||||
|
field name (`parent_column`) is shared between the struct you're
|
||||||
|
migrating and ones you're not — prefer compiler-guided manual fixes
|
||||||
|
or scope the script tightly.
|
||||||
|
|
||||||
|
## §5. Remaining open landscape (now trustworthy)
|
||||||
|
|
||||||
|
**Still `[/]` partial (need design/ADR or are larger):**
|
||||||
|
- **V1** relationship line-art visualization — **needs a
|
||||||
|
visualization ADR** (the most-requested deferred item).
|
||||||
|
- **S3 / V2** multi-result tabs — output-model redesign (single
|
||||||
|
buffer → tab abstraction).
|
||||||
|
- **A1** app-commands — blocked on `seed` (SD1) + `hint` (H2),
|
||||||
|
both unbuilt features.
|
||||||
|
- **DOC1** reference docs — writing effort.
|
||||||
|
- **X1** logging density — broad/mechanical (~25 `tracing` sites
|
||||||
|
today; `CLAUDE.md` wants "log liberally").
|
||||||
|
|
||||||
|
**`[ ]` not started:** H2 `hint`, SD1 `seed`, C4 m:n, B3
|
||||||
|
query-timeout, I1 multi-line, I1b readline, I5 cancellation, TT5
|
||||||
|
CI, TT4 PTY (spec-only), D1–D3 distribution, NFR-1…7.
|
||||||
|
|
||||||
|
## §6. Next job — candidates
|
||||||
|
|
||||||
|
No forced next step. By readiness:
|
||||||
|
1. **V1 relationship visualization** — design-first (its own ADR).
|
||||||
|
The user has flagged it repeatedly; it's the prominent open
|
||||||
|
design item.
|
||||||
|
2. **X1 logging** — mechanical, no ADR; brings the codebase to the
|
||||||
|
`CLAUDE.md` "log liberally" bar.
|
||||||
|
3. **TT5 CI** — test infra is solid (2193 green); no workflow yet.
|
||||||
|
4. The two T3 residuals (§4) — small messaging polish if they bug
|
||||||
|
you.
|
||||||
|
|
||||||
|
## §7. How to take over
|
||||||
|
|
||||||
|
1. Read handoffs 57 → 58 → 59, then `CLAUDE.md`,
|
||||||
|
`docs/requirements.md` (now with the `[/]` marker + the §2
|
||||||
|
reconciliation note), `docs/adr/README.md`.
|
||||||
|
2. **For relationships/FK: read ADR-0043.** The model is list-based
|
||||||
|
(`Vec<String>`); single-column is the one-element case.
|
||||||
|
3. Codebase on `main` at `4752ba2`, clean, 8 commits unpushed.
|
||||||
|
4. Process pins that paid off: **verify the tracker against code,
|
||||||
|
don't trust the marker**; **escalate genuine forks** (the four
|
||||||
|
ADR-0043 forks were the user's); **`/runda` after planning AND
|
||||||
|
after implementation** (the post-build pass found 3 real gaps);
|
||||||
|
**scope scripted edits tightly** (§4 scare). Commits
|
||||||
|
user-confirmed, append-only, no AI attribution.
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
**Date:** 2026-06-04 · **Status:** ready to build
|
**Date:** 2026-06-04 · **Status:** ready to build
|
||||||
|
|
||||||
Decisions for this work are recorded in
|
Decisions for this work are recorded in
|
||||||
[ADR-0042](../adr/0042-public-website-and-documentation-site.md): Astro 6 +
|
[ADR-0044](../adr/0044-public-website-and-documentation-site.md): Astro 6 +
|
||||||
Starlight + Tailwind v4; asciinema demos reusable in docs; the in-page WASM
|
Starlight + Tailwind v4; asciinema demos reusable in docs; the in-page WASM
|
||||||
playground deferred behind a stable demo seam; portable static hosting
|
playground deferred behind a stable demo seam; portable static hosting
|
||||||
(Vercel target); monorepo (`website/`); website is the canonical docs home;
|
(Vercel target); monorepo (`website/`); website is the canonical docs home;
|
||||||
@@ -128,7 +128,7 @@ project lifecycle + undo/history → Tier 3 teaching echo + EXPLAIN + errors +
|
|||||||
completion/highlighting → Tier 4 clipboard + hints + editing.
|
completion/highlighting → Tier 4 clipboard + hints + editing.
|
||||||
|
|
||||||
Conventions live in the **living style guide** `website/STYLE.md` (binding
|
Conventions live in the **living style guide** `website/STYLE.md` (binding
|
||||||
rules from ADR-0042 §7 — no engine name, **no "DSL"**, "planned" callouts —
|
rules from ADR-0044 §7 — no engine name, **no "DSL"**, "planned" callouts —
|
||||||
plus finer conventions and an open-decisions log for depth/splitting/example
|
plus finer conventions and an open-decisions log for depth/splitting/example
|
||||||
dataset/etc. as they settle). Sources to mine: `src/dsl/command.rs`,
|
dataset/etc. as they settle). Sources to mine: `src/dsl/command.rs`,
|
||||||
`src/dsl/grammar/*`, the REGISTRY, `en-US.yaml`, `docs/adr/*`,
|
`src/dsl/grammar/*`, the REGISTRY, `en-US.yaml`, `docs/adr/*`,
|
||||||
@@ -144,7 +144,7 @@ static host. `website/README.md` notes the Vercel preset (root dir
|
|||||||
`Demo.astro` exposes a stable contract (`{ src, title, height, autoplay }`).
|
`Demo.astro` exposes a stable contract (`{ src, title, height, autoplay }`).
|
||||||
At launch it renders `Cast.astro`; later a `Playground.astro` WASM island
|
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
|
swaps in behind the same props on the landing page and in docs, with zero
|
||||||
call-site changes. Boundary details are in ADR-0042 §3.
|
call-site changes. Boundary details are in ADR-0044 §3.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ call-site changes. Boundary details are in ADR-0042 §3.
|
|||||||
- Starlight link-check passes (broken internal links fail the build).
|
- Starlight link-check passes (broken internal links fail the build).
|
||||||
- Docs grep clean of forbidden terms: **no "DSL"**, no engine name.
|
- Docs grep clean of forbidden terms: **no "DSL"**, no engine name.
|
||||||
- A `dist/` static deploy works on Vercel (manual import) — confirms
|
- A `dist/` static deploy works on Vercel (manual import) — confirms
|
||||||
portability. (No CI gate yet, per ADR-0042 §4.)
|
portability. (No CI gate yet, per ADR-0044 §4.)
|
||||||
|
|
||||||
## Notes / recommendations (non-blocking)
|
## Notes / recommendations (non-blocking)
|
||||||
|
|
||||||
+209
-63
@@ -13,17 +13,32 @@ is a process failure.
|
|||||||
**Scope.** The list is intentionally coarse — each item is a
|
**Scope.** The list is intentionally coarse — each item is a
|
||||||
unit of "satisfied / not satisfied" judgement. When an item is
|
unit of "satisfied / not satisfied" judgement. When an item is
|
||||||
taken up for implementation, it is decomposed further in a
|
taken up for implementation, it is decomposed further in a
|
||||||
backlog (initially in this repo, later in GitHub issues once the
|
backlog (initially in this repo, now tracked as Gitea issues).
|
||||||
repo is pushed).
|
|
||||||
|
|
||||||
## Status legend
|
## Status legend
|
||||||
|
|
||||||
- `[ ]` — open, not yet implemented
|
- `[ ]` — open, not yet started
|
||||||
|
- `[/]` — partial / in progress: some of it is built and tested,
|
||||||
|
but named gaps remain. The entry states what works and what is
|
||||||
|
still missing. (Distinct from `[ ]`, which is genuinely
|
||||||
|
untouched, and from `[~]`, which is *deliberately* deferred
|
||||||
|
pending an ADR rather than half-built.)
|
||||||
- `[x]` — satisfied (implemented + tested)
|
- `[x]` — satisfied (implemented + tested)
|
||||||
- `[~]` — deferred, awaiting an ADR or further design before any
|
- `[~]` — deferred, awaiting an ADR or further design before any
|
||||||
implementation
|
implementation
|
||||||
- `[-]` — explicitly out of scope (rationale at the bottom)
|
- `[-]` — explicitly out of scope (rationale at the bottom)
|
||||||
|
|
||||||
|
> **Reconciliation note (2026-06-07).** A full audit of every
|
||||||
|
> `[ ]` item against the source found ~46 % of them mis-marked —
|
||||||
|
> overwhelmingly *under*-claimed (e.g. tab completion `I3` and
|
||||||
|
> syntax highlighting `I4` were shipped but marked "not yet
|
||||||
|
> implemented"). The binary legend was the root cause: a
|
||||||
|
> shipped feature, a half-built one, and an untouched one all
|
||||||
|
> wore the same `[ ]`. The `[/]` marker above was added to fix
|
||||||
|
> this, and the audited items were re-marked. When you implement
|
||||||
|
> against an item, move it `[ ]` → `[/]` → `[x]` rather than
|
||||||
|
> jumping straight to `[x]`, and keep the gap note current.
|
||||||
|
|
||||||
## Test baseline
|
## Test baseline
|
||||||
|
|
||||||
After ADR-0022 Amendment 6 (the curated SQL function-name list —
|
After ADR-0022 Amendment 6 (the curated SQL function-name list —
|
||||||
@@ -54,20 +69,34 @@ since ADR-0027.)
|
|||||||
|
|
||||||
## TUI shell
|
## TUI shell
|
||||||
|
|
||||||
- [ ] **S1** Three-region layout: items list (left), output
|
- [x] **S1** Three-region layout: items list (left), output
|
||||||
panel (right), input field (bottom).
|
panel (right), input field (bottom).
|
||||||
|
*(Verified 2026-06-07: `ui.rs:26-58` lays out a horizontal
|
||||||
|
split — items panel left, right column subdivided into output
|
||||||
|
panel / input field / hint panel; rendered every frame.)*
|
||||||
- [x] **S2** Items list shows tables and per-table indexes;
|
- [x] **S2** Items list shows tables and per-table indexes;
|
||||||
designed to extend to additional element kinds (relations,
|
designed to extend to additional element kinds (relations,
|
||||||
views, etc.) without restructuring.
|
views, etc.) without restructuring.
|
||||||
*(ADR-0025: the items panel renders a nested list — each
|
*(ADR-0025: the items panel renders a nested list — each
|
||||||
table with its index names indented beneath it. The nested
|
table with its index names indented beneath it. The nested
|
||||||
model is the extension point for future element kinds.)*
|
model is the extension point for future element kinds.)*
|
||||||
- [ ] **S3** Output panel renders a visualization of the
|
- [/] **S3** Output panel renders a visualization of the
|
||||||
currently selected item and supports multiple tabs.
|
currently selected item and supports multiple tabs.
|
||||||
- [ ] **S4** Hint area below the input field; keyboard-toggleable
|
*(Partial, verified 2026-06-07: single-element structure
|
||||||
|
visualisation renders (`output_render.rs:82-180`); **multiple
|
||||||
|
tabs are not implemented** — the output is one line buffer, no
|
||||||
|
tab abstraction. Same multi-tab gap as V2.)*
|
||||||
|
- [x] **S4** Hint area below the input field; keyboard-toggleable
|
||||||
for inspecting hints about the current input or last error.
|
for inspecting hints about the current input or last error.
|
||||||
- [ ] **S5** Mode label and distinct border style on the input
|
*(Verified 2026-06-07: `ui.rs:1088-1110` `render_hint_panel` /
|
||||||
|
`resolve_hint_lines` — a dynamic 1–`MAX_HINT_ROWS` panel below
|
||||||
|
the input showing ambient hints, candidates, or the last error.)*
|
||||||
|
- [x] **S5** Mode label and distinct border style on the input
|
||||||
field communicate the current input mode at all times.
|
field communicate the current input mode at all times.
|
||||||
|
*(Verified 2026-06-07: `ui.rs:896-934` `render_input_panel` —
|
||||||
|
a coloured, bold mode label plus a mode-distinct border colour
|
||||||
|
(`border` vs `border_advanced`), tracking the three-way
|
||||||
|
`EffectiveMode` incl. the one-shot `:` state.)*
|
||||||
- [x] **S6** Input-field validity indicator: a debounced
|
- [x] **S6** Input-field validity indicator: a debounced
|
||||||
`[ERR]` / `[WRN]` marker at the right edge of the input row,
|
`[ERR]` / `[WRN]` marker at the right edge of the input row,
|
||||||
summarising — before submit — whether the current command
|
summarising — before submit — whether the current command
|
||||||
@@ -89,12 +118,17 @@ since ADR-0027.)
|
|||||||
|
|
||||||
- [ ] **I1** Multi-line entry that auto-expands; Ctrl-Enter (or
|
- [ ] **I1** Multi-line entry that auto-expands; Ctrl-Enter (or
|
||||||
equivalent) submits, plain Enter inserts a newline.
|
equivalent) submits, plain Enter inserts a newline.
|
||||||
- [ ] **I1a** In-line cursor editing in the input field: Left /
|
- [x] **I1a** In-line cursor editing in the input field: Left /
|
||||||
Right arrows move the cursor by character (UTF-8 boundaries
|
Right arrows move the cursor by character (UTF-8 boundaries
|
||||||
honoured), Home / End jump to the extremes, Delete removes the
|
honoured), Home / End jump to the extremes, Delete removes the
|
||||||
character at the cursor, Backspace removes the character
|
character at the cursor, Backspace removes the character
|
||||||
before. Insertion happens at the cursor position. *(Implemented;
|
before. Insertion happens at the cursor position.
|
||||||
multi-line editing per I1 still pending.)*
|
*(Verified 2026-06-07: `app.rs:973-1005` `cursor_left` /
|
||||||
|
`cursor_right` (with `is_char_boundary` checks), Home/End,
|
||||||
|
Delete, Backspace, `insert_at_cursor`. This is single-line
|
||||||
|
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
|
- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
||||||
as aliases for Home / End for users on keyboards without those
|
as aliases for Home / End for users on keyboards without those
|
||||||
keys (and for ergonomics in command-driven workflows). Likely
|
keys (and for ergonomics in command-driven workflows). Likely
|
||||||
@@ -106,15 +140,33 @@ since ADR-0027.)
|
|||||||
navigable history is hydrated from the tail of
|
navigable history is hydrated from the tail of
|
||||||
`history.log` up to the same in-memory cap (Iter 6). Global
|
`history.log` up to the same in-memory cap (Iter 6). Global
|
||||||
rolling history is out of scope per OOS-6 / N4.)*
|
rolling history is out of scope per OOS-6 / N4.)*
|
||||||
- [ ] **I3** Tab completion for app commands, DSL keywords, table
|
- [x] **I3** Tab completion for app commands, DSL keywords, table
|
||||||
names, column names, and SQL keywords.
|
names, column names, and SQL keywords.
|
||||||
*(Refinement 2026-05-30, issue #15: SQL expression slots
|
*(Verified 2026-06-07 — this was mis-marked `[ ]` despite being
|
||||||
(`sql_expr_ident`) now also offer a curated set of SQL function
|
shipped: `src/completion.rs` is 2852 lines with ~100 tests;
|
||||||
|
`app.rs:898` binds Tab → `completion_tab_forward` (and
|
||||||
|
BackTab → `_backward`) with forward/backward cycling through a
|
||||||
|
candidate memo and a colour-coded candidate line in the hint
|
||||||
|
panel (`ui.rs:1125` `render_candidate_line`). All five
|
||||||
|
candidate categories work — app commands (via REGISTRY), DSL
|
||||||
|
keywords (walker `Expectation`), table names + column names
|
||||||
|
(`SchemaCache`), and SQL keywords/functions in advanced mode.
|
||||||
|
Refinement 2026-05-30, issue #15: SQL expression slots
|
||||||
|
(`sql_expr_ident`) also offer a curated set of SQL function
|
||||||
names — `KNOWN_SQL_FUNCTIONS` in `src/dsl/sql_functions.rs`,
|
names — `KNOWN_SQL_FUNCTIONS` in `src/dsl/sql_functions.rs`,
|
||||||
surfaced as a new `CandidateKind::Function` — ADR-0022 Amendment 6.
|
surfaced as `CandidateKind::Function` (ADR-0022 Amendment 6).
|
||||||
The broad tab-completion goal stays open.)*
|
The original "broad tab-completion" goal is met; any further
|
||||||
- [ ] **I4** Syntax highlighting for both the DSL and SQL.
|
polish is incremental, not a missing core.)*
|
||||||
*(Refinement 2026-05-29, issue #8: column data types now carry a
|
- [x] **I4** Syntax highlighting for both the DSL and SQL.
|
||||||
|
*(Verified 2026-06-07 — mis-marked `[ ]` despite being shipped:
|
||||||
|
`input_render.rs:64-113` lexes the input to styled byte-range
|
||||||
|
runs (`lex_to_runs_in_mode`) and renders them per-mode (DSL in
|
||||||
|
simple, SQL in advanced), with nine token classes in `theme.rs`
|
||||||
|
(`tok_keyword`, `tok_identifier`, `tok_string`, `tok_punct`,
|
||||||
|
`tok_flag`, `tok_error`, `tok_function`, `tok_type`) and
|
||||||
|
diagnostics overlaid (error/warning spans). Both surfaces are
|
||||||
|
highlighted; the core goal is met.
|
||||||
|
Refinement 2026-05-29, issue #8: column data types now carry a
|
||||||
dedicated `HighlightClass::Type` / `tok_type` colour, distinct from
|
dedicated `HighlightClass::Type` / `tok_type` colour, distinct from
|
||||||
identifiers and clause keywords — ADR-0022 Amendment 4; a further
|
identifiers and clause keywords — ADR-0022 Amendment 4; a further
|
||||||
refinement 2026-05-30, issue #15: SQL function-name candidates carry
|
refinement 2026-05-30, issue #15: SQL function-name candidates carry
|
||||||
@@ -172,24 +224,27 @@ since ADR-0027.)
|
|||||||
|
|
||||||
## App-level commands (per ADR-0003)
|
## App-level commands (per ADR-0003)
|
||||||
|
|
||||||
- [ ] **A1** All canonical app-level commands implemented and
|
- [/] **A1** All canonical app-level commands implemented and
|
||||||
available in both modes: `save`, `save as`, `load`, `new`,
|
available in both modes: `save`, `save as`, `load`, `new`,
|
||||||
`rebuild`, `export`, `import`, `seed`, `replay`, `undo`,
|
`rebuild`, `export`, `import`, `seed`, `replay`, `undo`,
|
||||||
`redo`, `mode`, `help`, `hint`, `quit`.
|
`redo`, `mode`, `help`, `hint`, `quit`.
|
||||||
*(Progress: `quit`/`q`, `mode simple|advanced`, `help`,
|
*(Partial, verified 2026-06-07: 13 of 15 implemented and
|
||||||
`save`, `save as`, `load`, `new`, `rebuild`, `export`,
|
available in both modes — `quit`/`q`, `mode simple|advanced`,
|
||||||
`import`, `replay` all implemented (Iterations 4 + 5;
|
`help`, `save`, `save as`, `load`, `new`, `rebuild`, `export`,
|
||||||
`replay` via ADR-0024 Phase E — see U4). `seed` in the
|
`import`, `replay`, `undo`, `redo` (REGISTRY in
|
||||||
seeding iteration; `undo` / `redo` in the U-series; `hint`
|
`grammar/app.rs:249-333`). **Missing: `seed`** (tracked as SD1)
|
||||||
with H2.)*
|
**and `hint`** (tracked as H2) — neither is registered. A1
|
||||||
|
closes when SD1 + H2 land.)*
|
||||||
|
|
||||||
## DSL data commands
|
## DSL data commands
|
||||||
|
|
||||||
- [ ] **C1** Table operations: create / drop / rename.
|
- [x] **C1** Table operations: create / drop / rename.
|
||||||
*(Progress: create + drop done; **rename done on the advanced
|
*(Verified 2026-06-07: create + drop done; **rename done on the
|
||||||
surface** — `ALTER TABLE … RENAME TO`, ADR-0035 §6 / 4h. A simple-mode
|
advanced surface** — `ALTER TABLE … RENAME TO`, ADR-0035 §6 / 4h
|
||||||
rename-table verb is deliberately not provided — table rename is
|
(`do_rename_table`, `db.rs:4821`). A simple-mode rename-table
|
||||||
advanced-mode only.)*
|
verb is **deliberately** not provided — table rename is
|
||||||
|
advanced-mode only — so the requirement is satisfied as
|
||||||
|
designed, not partial.)*
|
||||||
- [x] **C2** Column operations: add / drop / rename / change
|
- [x] **C2** Column operations: add / drop / rename / change
|
||||||
type. `drop column` and `rename column` use SQLite native
|
type. `drop column` and `rename column` use SQLite native
|
||||||
ALTER TABLE (3.35+ / 3.25+); `change column` routes through
|
ALTER TABLE (3.35+ / 3.25+); `change column` routes through
|
||||||
@@ -343,29 +398,54 @@ since ADR-0027.)
|
|||||||
*(Implemented per ADR-0014; auto-fills omitted shortid
|
*(Implemented per ADR-0014; auto-fills omitted shortid
|
||||||
columns and validates user-supplied values against the same
|
columns and validates user-supplied values against the same
|
||||||
alphabet and length range.)*
|
alphabet and length range.)*
|
||||||
- [ ] **T3** Compound primary keys handled end-to-end (DSL,
|
- [x] **T3** Compound primary keys handled end-to-end (DSL,
|
||||||
storage, display, FK reference).
|
storage, display, FK reference).
|
||||||
*(Progress: DSL grammar (`with pk a(int),b(int)`), storage, and
|
*(Done 2026-06-09 via **ADR-0043**: the FK-reference leg now
|
||||||
table-info description are all present; the FK iteration
|
works on both surfaces — DSL `add 1:n relationship from
|
||||||
references single-column PKs only — compound-key FK references
|
P.(a, b) to C.(x, y)` and SQL `FOREIGN KEY (a, b) REFERENCES
|
||||||
remain pending.)*
|
P(x, y)` (bare `REFERENCES P` auto-expands to the full PK).
|
||||||
|
References the parent's full compound PK matched positionally,
|
||||||
|
per-pair type-compat (ADR-0011); the FK is engine-enforced,
|
||||||
|
persisted (`columns: [a, b]` in `project.yaml`, comma-joined in
|
||||||
|
metadata), shown symmetrically by `describe`, and `--create-fk`
|
||||||
|
creates one child column per parent PK column. The relationship
|
||||||
|
model went list-based through all six layers, single-column
|
||||||
|
behaviour preserved (commit `b14f019`); 12 integration tests in
|
||||||
|
`tests/it/compound_fk.rs` plus the existing single-column suite
|
||||||
|
as the regression net. The earlier-noted (2026-06-07) breakdown:*
|
||||||
|
*compound-PK **declaration** (`with pk a(int),b(int)`),
|
||||||
|
**storage** (`primary_key: Vec<String>`), and **display** were
|
||||||
|
already present and tested. The FK-reference leg — once an
|
||||||
|
ADR-first ~15–20-site change across the relationship model — is
|
||||||
|
what ADR-0043 delivered (back-compat dropped by user decision, so
|
||||||
|
no migration was needed). Subset / non-PK (UNIQUE-target) FK
|
||||||
|
references stay out of scope.)*
|
||||||
|
|
||||||
## Visualizations
|
## Visualizations
|
||||||
|
|
||||||
- [ ] **V1** Single-element views render in the output pane: a
|
- [/] **V1** Single-element views render in the output pane: a
|
||||||
selected table as its structure (columns, types, keys,
|
selected table as its structure (columns, types, keys,
|
||||||
constraints); a selected relationship as two tables joined by
|
constraints); a selected relationship as two tables joined by
|
||||||
a line.
|
a line.
|
||||||
*(Progress: a basic structure view (column rows with SQLite
|
*(Partial, verified 2026-06-07: the **table-structure** half is
|
||||||
type names) is rendered after each successful DDL; pretty
|
done — `output_render.rs:82-180` renders columns / types /
|
||||||
rendering, selection nav, and relationship line-art pending —
|
constraints / indexes in a box-drawing table, with relationship
|
||||||
see V4 for the broader direction.)*
|
metadata as `References:` / `Referenced by:` prose
|
||||||
- [ ] **V2** SQL query results render as a dynamic table view in
|
(`A.col → B.col`). The **relationship-as-line-art** half — two
|
||||||
|
tables drawn side by side with a connecting line — is **not
|
||||||
|
implemented** (deferred per `output_render.rs` §5 OOS-1, ADR
|
||||||
|
pending). This is the relationship-visualisation piece that has
|
||||||
|
been repeatedly pushed away; it is the substantive open part of
|
||||||
|
V1. Selection-nav and the broader journal direction live in V4.)*
|
||||||
|
- [/] **V2** SQL query results render as a dynamic table view in
|
||||||
the output pane, with multiple result tabs supported.
|
the output pane, with multiple result tabs supported.
|
||||||
*(Progress: a basic aligned-column data view is rendered for
|
*(Partial, verified 2026-06-07: the **table view** is done —
|
||||||
`show data` and after every write (ADR-0014). Pretty
|
`output_render.rs:38-72` `render_data_table` renders a
|
||||||
box-drawing tables with truncation/scroll handling, plus
|
box-drawing frame with aligned columns (numeric right, text
|
||||||
multi-tab support, remain in V4 territory.)*
|
left) and NULL/control-char sanitisation, for `show data` and
|
||||||
|
after every write (ADR-0014). **Missing: multiple result tabs**
|
||||||
|
— the output is a single `VecDeque<OutputLine>` with no tab
|
||||||
|
abstraction (same gap as S3). Multi-tab sits in V4 territory.)*
|
||||||
- [~] **V3** Full ER-diagram export (whole-database graph, viewed
|
- [~] **V3** Full ER-diagram export (whole-database graph, viewed
|
||||||
outside the TUI) — low priority; design and ADR pending.
|
outside the TUI) — low priority; design and ADR pending.
|
||||||
- [~] **V4** Output panel as a *scrollable per-session log* with
|
- [~] **V4** Output panel as a *scrollable per-session log* with
|
||||||
@@ -381,10 +461,34 @@ since ADR-0027.)
|
|||||||
buffer is in, with new output snapping the view to the most
|
buffer is in, with new output snapping the view to the most
|
||||||
recent. The full V4 scope — smart structure rendering, log
|
recent. The full V4 scope — smart structure rendering, log
|
||||||
styling, Markdown export, scroll indicator — remains pending.)*
|
styling, Markdown export, scroll indicator — remains pending.)*
|
||||||
- [ ] **V5** `show <kind> [<name>]` family of commands for
|
- [x] **V5** `show <kind> [<name>]` family of commands for
|
||||||
redisplaying schema info on demand. *(Progress: `show table
|
redisplaying schema info on demand.
|
||||||
<name>` and `show data <Table>` implemented;
|
*(Done 2026-06-07: `show table <name>` + `show data <Table>`
|
||||||
`show tables`, `show relationships`, etc. pending.)*
|
(single-item) plus the list-all family `show tables` /
|
||||||
|
`show relationships` / `show indexes` — the latter three landed
|
||||||
|
as `Command::ShowList { kind }` (one variant, `grammar/data.rs`),
|
||||||
|
a read-only worker `show_list` formatting count-headed lists from
|
||||||
|
the same helpers the items panel uses, with help + parse-usage
|
||||||
|
entries and 10 integration tests (`tests/it/show_list.rs`). The
|
||||||
|
one remaining member of the `[<name>]` clause — singular
|
||||||
|
per-item detail for relationships/indexes — is split out as
|
||||||
|
**V5a** below so it has a tracked home rather than living as a
|
||||||
|
footnote here.)*
|
||||||
|
- [x] **V5a** Singular per-item detail views `show relationship
|
||||||
|
<name>` / `show index <name>` — the `[<name>]` half of V5 for
|
||||||
|
the relationship and index kinds (the table kind already has
|
||||||
|
`show table <name>`).
|
||||||
|
*(Done 2026-06-07: folded into `Command::ShowList { kind, name:
|
||||||
|
Option<String> }` — `name: Some(_)` is the singular form. Two
|
||||||
|
grammar branches (`relationship <name>` / `index <name>`,
|
||||||
|
reusing the `Relationships`/`Indexes` completion sources for the
|
||||||
|
name slot), a worker `do_show_one` rendering a labelled detail
|
||||||
|
block (endpoints + ON DELETE/UPDATE for a relationship; table,
|
||||||
|
columns, uniqueness for an index) or a friendly "No relationship/
|
||||||
|
index named `X`." line, reusing the V5 `ShowList` render path.
|
||||||
|
Help + parse-usage entries + two ADR-0042 near-miss matrix rows;
|
||||||
|
5 added integration tests. V5's `[<name>]` clause is now
|
||||||
|
complete across all three kinds.)*
|
||||||
- [x] **V6** Copy the output panel to the system clipboard
|
- [x] **V6** Copy the output panel to the system clipboard
|
||||||
(issue #11, ADR-0041). `copy` / `copy all` copy the whole
|
(issue #11, ADR-0041). `copy` / `copy all` copy the whole
|
||||||
panel; `copy last` copies the most recent command's output.
|
panel; `copy last` copies the most recent command's output.
|
||||||
@@ -557,14 +661,14 @@ since ADR-0027.)
|
|||||||
migration sweep of all other user-facing strings, advanced-mode
|
migration sweep of all other user-facing strings, advanced-mode
|
||||||
SQL-error sanitization (§OOS-2), and `messages` persistence
|
SQL-error sanitization (§OOS-2), and `messages` persistence
|
||||||
(§OOS-3, awaits the settings ADR).)*
|
(§OOS-3, awaits the settings ADR).)*
|
||||||
- [ ] **H1a** Strong syntax-help in parse errors. When the user
|
- [x] **H1a** Strong syntax-help in parse errors. When the user
|
||||||
types something near-correct (e.g. `insert into T ('Oli')` —
|
types something near-correct (e.g. `insert into T ('Oli')` —
|
||||||
forgotten `values`; or `update T set x=1` — missing WHERE),
|
forgotten `values`; or `update T set x=1` — missing WHERE),
|
||||||
the error should *name the missing keyword or clause* rather
|
the error should *name the missing keyword or clause* rather
|
||||||
than just point at the unexpected character. This is a
|
than just point at the unexpected character. This is a
|
||||||
separate effort from H1 (which targets database errors); it
|
separate effort from H1 (which targets database errors); it
|
||||||
targets parser errors. Pending — multiple targeted fixes
|
targets parser errors. *(Done via the **ADR-0042** systematic
|
||||||
shipping piecemeal so far (e.g. `values` becoming optional in
|
pass, 2026-06-06.)* Built piecemeal first (e.g. `values` becoming optional in
|
||||||
INSERT removes one such case; ADR-0024's typed value slots
|
INSERT removes one such case; ADR-0024's typed value slots
|
||||||
give per-column-type rejection wording; `insert into T (col)`
|
give per-column-type rejection wording; `insert into T (col)`
|
||||||
with no `values` clause now flags "looks like Form A — add
|
with no `values` clause now flags "looks like Form A — add
|
||||||
@@ -589,19 +693,41 @@ since ADR-0027.)
|
|||||||
`col`…"* message at typing time, counted against the user-fillable
|
`col`…"* message at typing time, counted against the user-fillable
|
||||||
columns, with `serial`/`shortid` auto-fill named; new keys
|
columns, with `serial`/`shortid` auto-fill named; new keys
|
||||||
`diagnostic.insert_arity_mismatch_form_b_simple` /
|
`diagnostic.insert_arity_mismatch_form_b_simple` /
|
||||||
`diagnostic.insert_arity_mismatch_all_auto`). A systematic pass is
|
`diagnostic.insert_arity_mismatch_all_auto`). The **ADR-0042
|
||||||
still pending.
|
systematic pass** then closed it: a per-command near-miss matrix
|
||||||
|
(`tests/it/parse_error_pedagogy.rs`) locks every entry word's
|
||||||
|
bare / missing-clause / wrong-token cases plus the committed
|
||||||
|
multi-forms, in both modes; friendlier labels landed (`add` →
|
||||||
|
`1:n relationship`; bare `select` → "a projection: …"); the
|
||||||
|
usage block became mode-aware (advanced shows the SQL forms
|
||||||
|
plus the still-valid DSL fallback forms, SQL-first); `with`
|
||||||
|
got its own CTE template; and `cross join … on` now teaches
|
||||||
|
that a CROSS JOIN takes no ON clause. The advanced-SQL
|
||||||
|
diagnostics that the survey thought missing (INSERT…SELECT
|
||||||
|
count, RETURNING column scope) were verified already present.
|
||||||
|
One low-priority residual is deferred by decision: at *submit*
|
||||||
|
time a non-projection expression position (bare `where `,
|
||||||
|
`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
|
- [ ] **H2** `hint` provides contextual help for the current
|
||||||
input or the most recent error.
|
input or the most recent error.
|
||||||
- [ ] **H3** `help` provides general reference and per-command
|
- [x] **H3** `help` provides general reference and per-command
|
||||||
help.
|
help.
|
||||||
*(Progress: the `help` command lists currently-supported
|
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
|
||||||
commands + DSL grammar reference + types. As of ADR-0024
|
intro + the full command list (REGISTRY × `help_id`, so new
|
||||||
§help_id it is assembled by iterating the command REGISTRY
|
commands appear automatically) + the type reference + a footer
|
||||||
and translating each `CommandNode.help_id`, so a new command
|
pointing at the focused form. **Per-command help** is `help
|
||||||
appears automatically. A general reference and `help
|
<command>` (H3's new piece): the HELP node took an optional
|
||||||
<command>`-style detailed per-command help are still the
|
single-word topic (`BarePath`), `AppCommand::Help { topic }`,
|
||||||
missing pieces.)*
|
and `note_help_topic` renders the block(s) of every command
|
||||||
|
sharing that entry word — so `help create` covers both create
|
||||||
|
forms — plus `help types` for the type reference and a friendly
|
||||||
|
"no help for `X`" pointer for an unknown topic. Help/usage
|
||||||
|
strings catalogued + key-registered; 9 integration tests
|
||||||
|
(`tests/it/help_command.rs`). A richer *narrative* overview
|
||||||
|
(modes, the `:` escape, syntax conventions) is reference-docs
|
||||||
|
scope, tracked under **DOC1** — not part of H3.)*
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
@@ -636,7 +762,7 @@ since ADR-0027.)
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [ ] **DOC1** User- and student-facing reference
|
- [/] **DOC1** User- and student-facing reference
|
||||||
documentation under `docs/`: the DSL command surface,
|
documentation under `docs/`: the DSL command surface,
|
||||||
the type system, and the boundaries of simple mode.
|
the type system, and the boundaries of simple mode.
|
||||||
`docs/simple-mode-limitations.md` is the first piece —
|
`docs/simple-mode-limitations.md` is the first piece —
|
||||||
@@ -644,6 +770,11 @@ since ADR-0027.)
|
|||||||
reference. Distinct from in-app `help` (`H3`), the
|
reference. Distinct from in-app `help` (`H3`), the
|
||||||
interactive tutorial system (`TU1`), and the sharing
|
interactive tutorial system (`TU1`), and the sharing
|
||||||
recipes under `E2`.
|
recipes under `E2`.
|
||||||
|
*(Partial, verified 2026-06-07: `docs/simple-mode-limitations.md`
|
||||||
|
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/`.)*
|
||||||
|
|
||||||
## Testing (per ADR-0008)
|
## Testing (per ADR-0008)
|
||||||
|
|
||||||
@@ -657,14 +788,29 @@ since ADR-0027.)
|
|||||||
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
- [~] **TT4** Tier 4: PTY-based end-to-end for the four critical
|
||||||
flows named in ADR-0008 (cold launch → DDL → quit; save →
|
flows named in ADR-0008 (cold launch → DDL → quit; save →
|
||||||
reopen; export → import → rebuild; undo after DROP).
|
reopen; export → import → rebuild; undo after DROP).
|
||||||
|
*(Verified 2026-06-07: **nothing is wired** — no
|
||||||
|
`portable-pty` / `expectrl` / `vt100` dependencies, no PTY test
|
||||||
|
files; ADR-0008 §Tier-4 is a specification only. The Tier-3
|
||||||
|
`tests/it/*_e2e.rs` files are synthetic event-loop tests, not
|
||||||
|
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.
|
stable Rust.
|
||||||
|
|
||||||
## Cross-cutting
|
## Cross-cutting
|
||||||
|
|
||||||
- [ ] **X1** Comprehensive logging via the project's logging
|
- [/] **X1** Comprehensive logging via the project's logging
|
||||||
infrastructure per `CLAUDE.md` (decision points, parameter
|
infrastructure per `CLAUDE.md` (decision points, parameter
|
||||||
values, fallback paths).
|
values, fallback paths).
|
||||||
|
*(Partial, verified 2026-06-07: the logging **harness** is
|
||||||
|
wired — `src/logging.rs` sets up file-backed `tracing` with an
|
||||||
|
env filter — but instrumentation is **sparse**: ~25 `tracing::`
|
||||||
|
call sites across the tree, concentrated in `runtime.rs` and
|
||||||
|
`undo.rs` and mostly error/warning on failure paths. The
|
||||||
|
decision-point / parameter-value / fallback-path coverage the
|
||||||
|
`CLAUDE.md` "log liberally" standard calls for — especially in
|
||||||
|
`db.rs`, the parser, and the executors — is largely absent.)*
|
||||||
- [~] **X2** Language: English-only for v1; multi-language is an
|
- [~] **X2** Language: English-only for v1; multi-language is an
|
||||||
open question to revisit later.
|
open question to revisit later.
|
||||||
- [~] **X3** Accessibility: TUI screen-reader support is
|
- [~] **X3** Accessibility: TUI screen-reader support is
|
||||||
|
|||||||
+98
-26
@@ -605,6 +605,15 @@ impl App {
|
|||||||
self.handle_dsl_explain_success(&command, &plan);
|
self.handle_dsl_explain_success(&command, &plan);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
AppEvent::DslShowListSucceeded { command, lines } => {
|
||||||
|
// Mark the echo ✓ (ADR-0040), then emit the
|
||||||
|
// worker-formatted list as system output lines.
|
||||||
|
self.note_ok_summary(&command);
|
||||||
|
for line in lines {
|
||||||
|
self.note_system(line);
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
AppEvent::DslInsertSucceeded { command, result } => {
|
AppEvent::DslInsertSucceeded { command, result } => {
|
||||||
self.handle_dsl_insert_success(&command, &result);
|
self.handle_dsl_insert_success(&command, &result);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -1312,8 +1321,11 @@ impl App {
|
|||||||
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
|
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
|
||||||
match cmd {
|
match cmd {
|
||||||
AppCommand::Quit => vec![Action::Quit],
|
AppCommand::Quit => vec![Action::Quit],
|
||||||
AppCommand::Help => {
|
AppCommand::Help { topic } => {
|
||||||
self.note_help();
|
match &topic {
|
||||||
|
Some(t) => self.note_help_topic(t),
|
||||||
|
None => self.note_help(),
|
||||||
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
||||||
@@ -1338,11 +1350,11 @@ impl App {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
AppCommand::Import { path, target } => {
|
AppCommand::Import { path, target } => {
|
||||||
// The path-bearing import goes through the
|
// A path-bearing import carries a non-empty path
|
||||||
// pre-chumsky source-slice (parser.rs), which
|
// from the walker. Bare `import` parses with an
|
||||||
// already validated non-empty path. Bare
|
// empty path string — surface the usage hint here
|
||||||
// `import` returns from chumsky with an empty
|
// at dispatch (not a parse error; ADR-0024 replaced
|
||||||
// path string — surface the usage error.
|
// the old chumsky source-slice path).
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
self.note_error(crate::t!("project.import_usage"));
|
self.note_error(crate::t!("project.import_usage"));
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -1521,7 +1533,7 @@ impl App {
|
|||||||
for note in notes {
|
for note in notes {
|
||||||
self.note_error(note);
|
self.note_error(note);
|
||||||
}
|
}
|
||||||
self.note_error(render_usage_block(input));
|
self.note_error(render_usage_block(input, mode));
|
||||||
return vec![Action::JournalFailure {
|
return vec![Action::JournalFailure {
|
||||||
source: input.to_string(),
|
source: input.to_string(),
|
||||||
}];
|
}];
|
||||||
@@ -1601,7 +1613,7 @@ impl App {
|
|||||||
// known command-entry keyword was consumed) or
|
// known command-entry keyword was consumed) or
|
||||||
// the available-commands fallback (§5).
|
// the available-commands fallback (§5).
|
||||||
if let ParseError::Invalid { .. } = &err {
|
if let ParseError::Invalid { .. } = &err {
|
||||||
self.note_error(render_usage_block(input));
|
self.note_error(render_usage_block(input, mode));
|
||||||
}
|
}
|
||||||
// ADR-0034 §1/§2: a submitted line that failed to
|
// ADR-0034 §1/§2: a submitted line that failed to
|
||||||
// parse is journalled `err` so it is recallable
|
// parse is journalled `err` so it is recallable
|
||||||
@@ -1956,12 +1968,14 @@ impl App {
|
|||||||
),
|
),
|
||||||
C::AddRelationship {
|
C::AddRelationship {
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
..
|
..
|
||||||
} => (
|
} => (
|
||||||
Operation::AddRelationship,
|
Operation::AddRelationship,
|
||||||
Some(parent_table.as_str()),
|
Some(parent_table.as_str()),
|
||||||
Some(parent_column.as_str()),
|
// Single-column facts model (ADR-0019): the first PK
|
||||||
|
// column for a compound FK (ADR-0043).
|
||||||
|
parent_columns.first().map(String::as_str),
|
||||||
),
|
),
|
||||||
C::DropRelationship { selector } => match selector {
|
C::DropRelationship { selector } => match selector {
|
||||||
RelationshipSelector::Endpoints {
|
RelationshipSelector::Endpoints {
|
||||||
@@ -2007,6 +2021,9 @@ impl App {
|
|||||||
C::ShowData { name, .. } | C::ShowTable { name } => {
|
C::ShowData { name, .. } | C::ShowTable { name } => {
|
||||||
(Operation::Query, Some(name.as_str()), None)
|
(Operation::Query, Some(name.as_str()), None)
|
||||||
}
|
}
|
||||||
|
// A `show <kind>` list spans no single table; a failure
|
||||||
|
// routes through Query with no table fallback.
|
||||||
|
C::ShowList { .. } => (Operation::Query, None, None),
|
||||||
// A SQL `SELECT` carries only its statement text —
|
// A SQL `SELECT` carries only its statement text —
|
||||||
// no single table name to fall back on. A query
|
// no single table name to fall back on. A query
|
||||||
// failure routes through `Operation::Query`.
|
// failure routes through `Operation::Query`.
|
||||||
@@ -2393,6 +2410,49 @@ impl App {
|
|||||||
.lines()
|
.lines()
|
||||||
.map(str::to_string),
|
.map(str::to_string),
|
||||||
);
|
);
|
||||||
|
// H3: point at the focused per-command form.
|
||||||
|
lines.push(crate::t!("help.detail_hint"));
|
||||||
|
for line in lines {
|
||||||
|
self.note_system(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focused per-command help (H3): `help <topic>`, where `topic`
|
||||||
|
/// is a command entry word (`insert`, `create`, `show`, …) or
|
||||||
|
/// the special `types`. Renders the help block(s) of every
|
||||||
|
/// command sharing that entry word — so `help create` covers
|
||||||
|
/// both the DSL and SQL create forms — or a friendly pointer
|
||||||
|
/// back to `help` when nothing matches.
|
||||||
|
fn note_help_topic(&mut self, topic: &str) {
|
||||||
|
use crate::dsl::grammar::REGISTRY;
|
||||||
|
|
||||||
|
let topic = topic.trim();
|
||||||
|
// `help types` re-shows just the type reference.
|
||||||
|
if topic.eq_ignore_ascii_case("types") {
|
||||||
|
for line in crate::t!("help.types_reference").lines() {
|
||||||
|
self.note_system(line.to_string());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
for (command, _category) in REGISTRY {
|
||||||
|
let Some(help_id) = command.help_id else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if command.entry.matches(topic) {
|
||||||
|
let key = format!("help.{help_id}");
|
||||||
|
let body = crate::friendly::translate(&key, &[]);
|
||||||
|
lines.extend(body.lines().map(str::to_string));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
// No command owns that entry word — name it and point
|
||||||
|
// back at the full list rather than failing silently.
|
||||||
|
self.note_system(crate::t!("help.unknown_topic", topic = topic));
|
||||||
|
return;
|
||||||
|
}
|
||||||
for line in lines {
|
for line in lines {
|
||||||
self.note_system(line);
|
self.note_system(line);
|
||||||
}
|
}
|
||||||
@@ -2557,16 +2617,18 @@ fn parse_error_message(err: &ParseError) -> String {
|
|||||||
/// renders every catalog template — multi-form families like
|
/// renders every catalog template — multi-form families like
|
||||||
/// `drop` show every variant. Otherwise the fallback lists every
|
/// `drop` show every variant. Otherwise the fallback lists every
|
||||||
/// entry keyword alphabetically.
|
/// entry keyword alphabetically.
|
||||||
fn render_usage_block(input: &str) -> String {
|
fn render_usage_block(input: &str, mode: Mode) -> String {
|
||||||
// A multi-form command that has committed to a form
|
// A multi-form command that has committed to a form
|
||||||
// (`add index …`) shows only that form's usage; a bare
|
// (`add index …`) shows only that form's usage; a bare
|
||||||
// multi-form entry word (`add`) shows the whole family.
|
// multi-form entry word (`add`) shows the whole family.
|
||||||
|
// Mode-aware (ADR-0042 G3): in advanced mode a shared entry
|
||||||
|
// word shows its SQL forms, not the DSL templates.
|
||||||
let catalog_keys: Vec<&'static str> =
|
let catalog_keys: Vec<&'static str> =
|
||||||
crate::dsl::grammar::usage_key_for_input(input)
|
crate::dsl::grammar::usage_key_for_input_in_mode(input, mode)
|
||||||
.map(|key| vec![key])
|
.map(|key| vec![key])
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
crate::dsl::grammar::usage_keys_for_input(input)
|
crate::dsl::grammar::usage_keys_for_input_in_mode(input, mode)
|
||||||
.map(|(_word, all)| all.to_vec())
|
.map(|(_word, all)| all)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !catalog_keys.is_empty() {
|
if !catalog_keys.is_empty() {
|
||||||
@@ -2820,13 +2882,23 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tab_cycles_forward_through_multi_candidate_set() {
|
fn tab_cycles_forward_through_multi_candidate_set() {
|
||||||
|
// `show ` offers five subcommands in grammar order:
|
||||||
|
// data / table / tables / relationships / indexes (V5).
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
type_str(&mut app, "show ");
|
type_str(&mut app, "show ");
|
||||||
app.update(key(KeyCode::Tab));
|
for expected in [
|
||||||
assert_eq!(app.input, "show data");
|
"show data",
|
||||||
app.update(key(KeyCode::Tab));
|
"show table",
|
||||||
assert_eq!(app.input, "show table");
|
"show tables",
|
||||||
// Wrap-around.
|
"show relationships",
|
||||||
|
"show indexes",
|
||||||
|
"show relationship",
|
||||||
|
"show index",
|
||||||
|
] {
|
||||||
|
app.update(key(KeyCode::Tab));
|
||||||
|
assert_eq!(app.input, expected);
|
||||||
|
}
|
||||||
|
// Wrap-around back to the first.
|
||||||
app.update(key(KeyCode::Tab));
|
app.update(key(KeyCode::Tab));
|
||||||
assert_eq!(app.input, "show data");
|
assert_eq!(app.input, "show data");
|
||||||
}
|
}
|
||||||
@@ -2835,12 +2907,12 @@ mod tests {
|
|||||||
fn shift_tab_cycles_backward_starting_from_last() {
|
fn shift_tab_cycles_backward_starting_from_last() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
type_str(&mut app, "show ");
|
type_str(&mut app, "show ");
|
||||||
app.update(key(KeyCode::BackTab));
|
// Backward starts from the last candidate (`index`, the
|
||||||
assert_eq!(app.input, "show table");
|
// V5a singular form).
|
||||||
app.update(key(KeyCode::BackTab));
|
for expected in ["show index", "show relationship", "show indexes"] {
|
||||||
assert_eq!(app.input, "show data");
|
app.update(key(KeyCode::BackTab));
|
||||||
app.update(key(KeyCode::BackTab));
|
assert_eq!(app.input, expected);
|
||||||
assert_eq!(app.input, "show table");
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+16
-2
@@ -1661,9 +1661,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_offers_data_and_table_alphabetised() {
|
fn show_offers_all_subcommands() {
|
||||||
|
// `show` branches: data / table (singular) plus the V5
|
||||||
|
// list-all forms tables / relationships / indexes, in
|
||||||
|
// grammar-declaration order.
|
||||||
let cs = cands("show ", 5);
|
let cs = cands("show ", 5);
|
||||||
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
|
assert_eq!(
|
||||||
|
cs,
|
||||||
|
vec![
|
||||||
|
"data".to_string(),
|
||||||
|
"table".to_string(),
|
||||||
|
"tables".to_string(),
|
||||||
|
"relationships".to_string(),
|
||||||
|
"indexes".to_string(),
|
||||||
|
"relationship".to_string(),
|
||||||
|
"index".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+101
-14
@@ -29,15 +29,20 @@ pub struct SqlForeignKey {
|
|||||||
/// FK or an unnamed table FK (auto-named at execution per
|
/// FK or an unnamed table FK (auto-named at execution per
|
||||||
/// ADR-0013).
|
/// ADR-0013).
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
/// The column in the table being created that holds the FK.
|
/// The column(s) in the table being created that hold the FK.
|
||||||
pub child_column: String,
|
/// One element for a single-column FK; ordered list for a
|
||||||
|
/// compound FK (ADR-0043). Positionally paired with
|
||||||
|
/// `parent_columns`.
|
||||||
|
pub child_columns: Vec<String>,
|
||||||
/// The referenced (parent) table — may be the table being created
|
/// The referenced (parent) table — may be the table being created
|
||||||
/// (a self-referencing FK).
|
/// (a self-referencing FK).
|
||||||
pub parent_table: String,
|
pub parent_table: String,
|
||||||
/// The referenced parent column. `None` for the bare
|
/// The referenced parent column(s), positionally paired with
|
||||||
/// `REFERENCES <parent>` form, resolved at execution to the
|
/// `child_columns`. `None` for the bare `REFERENCES <parent>`
|
||||||
/// parent's single-column primary key (ADR-0035 §4b, user-confirmed).
|
/// form, resolved at execution to the parent's primary key —
|
||||||
pub parent_column: Option<String>,
|
/// the single-column PK, or (ADR-0043 F-D) the full compound PK
|
||||||
|
/// when the child arity matches.
|
||||||
|
pub parent_columns: Option<Vec<String>>,
|
||||||
pub on_delete: ReferentialAction,
|
pub on_delete: ReferentialAction,
|
||||||
pub on_update: ReferentialAction,
|
pub on_update: ReferentialAction,
|
||||||
}
|
}
|
||||||
@@ -253,9 +258,14 @@ pub enum Command {
|
|||||||
AddRelationship {
|
AddRelationship {
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
parent_table: String,
|
parent_table: String,
|
||||||
parent_column: String,
|
/// Parent (referenced) PK column(s); one element for a
|
||||||
|
/// single-column FK, ordered list for a compound FK
|
||||||
|
/// (ADR-0043). Positionally paired with `child_columns`.
|
||||||
|
parent_columns: Vec<String>,
|
||||||
child_table: String,
|
child_table: String,
|
||||||
child_column: String,
|
/// Child (referencing) column(s), positionally paired with
|
||||||
|
/// `parent_columns`; equal, non-zero length.
|
||||||
|
child_columns: Vec<String>,
|
||||||
on_delete: ReferentialAction,
|
on_delete: ReferentialAction,
|
||||||
on_update: ReferentialAction,
|
on_update: ReferentialAction,
|
||||||
create_fk: bool,
|
create_fk: bool,
|
||||||
@@ -332,6 +342,17 @@ pub enum Command {
|
|||||||
ShowTable {
|
ShowTable {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
/// Re-display a schema collection in the output (V5/V5a). With
|
||||||
|
/// `name: None`, the whole collection — every table,
|
||||||
|
/// relationship, or index (the list-all forms). With
|
||||||
|
/// `name: Some(_)`, one named item's detail — `show
|
||||||
|
/// relationship <name>` / `show index <name>` (V5a); the table
|
||||||
|
/// singular is the separate `ShowTable`, so a named `Tables`
|
||||||
|
/// never occurs. Read-only; pure display, no schema change.
|
||||||
|
ShowList {
|
||||||
|
kind: ShowListKind,
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
/// Insert a single row. `columns` is `None` for the natural-
|
/// Insert a single row. `columns` is `None` for the natural-
|
||||||
/// order short form (`insert into T values (...)`); the
|
/// order short form (`insert into T values (...)`); the
|
||||||
/// executor fills in the column list by walking the schema.
|
/// executor fills in the column list by walking the schema.
|
||||||
@@ -485,8 +506,14 @@ pub enum Command {
|
|||||||
pub enum AppCommand {
|
pub enum AppCommand {
|
||||||
/// Exit cleanly. Accepts the `q` alias.
|
/// Exit cleanly. Accepts the `q` alias.
|
||||||
Quit,
|
Quit,
|
||||||
/// Show in-app help. Body comes from `help.in_app_body`.
|
/// Show in-app help (H3). With no `topic`, the full command
|
||||||
Help,
|
/// list + types reference; with a `topic` (a command entry
|
||||||
|
/// word like `insert` / `create` / `show`, or `types`), the
|
||||||
|
/// focused detail for that command (or command group sharing
|
||||||
|
/// the entry word).
|
||||||
|
Help {
|
||||||
|
topic: Option<String>,
|
||||||
|
},
|
||||||
/// Rebuild `playground.db` from `project.yaml` + data/, with
|
/// Rebuild `playground.db` from `project.yaml` + data/, with
|
||||||
/// confirmation modal.
|
/// confirmation modal.
|
||||||
Rebuild,
|
Rebuild,
|
||||||
@@ -746,6 +773,36 @@ pub enum IndexSelector {
|
|||||||
Columns { table: String, columns: Vec<String> },
|
Columns { table: String, columns: Vec<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which schema collection a `show <kind>` list command displays (V5).
|
||||||
|
///
|
||||||
|
/// The bare plural forms list every item of the kind across the
|
||||||
|
/// project; the singular `show table <name>` (a separate
|
||||||
|
/// `Command::ShowTable`) shows one. The singular `show
|
||||||
|
/// relationship <name>` / `show index <name>` forms are not yet
|
||||||
|
/// provided — only the list-all forms land here.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ShowListKind {
|
||||||
|
/// `show tables` — every user table (internal `__rdbms_*`
|
||||||
|
/// tables excluded, as in the items panel).
|
||||||
|
Tables,
|
||||||
|
/// `show relationships` — every declared FK relationship.
|
||||||
|
Relationships,
|
||||||
|
/// `show indexes` — every index across all tables.
|
||||||
|
Indexes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShowListKind {
|
||||||
|
/// The full command name for the `name()` / echo surface.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn command_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Tables => "show tables",
|
||||||
|
Self::Relationships => "show relationships",
|
||||||
|
Self::Indexes => "show indexes",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The action of an advanced-mode `ALTER TABLE` (ADR-0035 §4).
|
/// The action of an advanced-mode `ALTER TABLE` (ADR-0035 §4).
|
||||||
///
|
///
|
||||||
/// Sub-phase 4e carries the column actions; 4f adds `AlterColumnType`;
|
/// Sub-phase 4e carries the column actions; 4f adds `AlterColumnType`;
|
||||||
@@ -860,6 +917,17 @@ impl Command {
|
|||||||
Self::AddConstraint { .. } => "add constraint",
|
Self::AddConstraint { .. } => "add constraint",
|
||||||
Self::DropConstraint { .. } => "drop constraint",
|
Self::DropConstraint { .. } => "drop constraint",
|
||||||
Self::ShowTable { .. } => "show table",
|
Self::ShowTable { .. } => "show table",
|
||||||
|
// A named item reports the singular verb; the list-all
|
||||||
|
// forms report the plural (`kind.command_name()`).
|
||||||
|
Self::ShowList {
|
||||||
|
kind: ShowListKind::Relationships,
|
||||||
|
name: Some(_),
|
||||||
|
} => "show relationship",
|
||||||
|
Self::ShowList {
|
||||||
|
kind: ShowListKind::Indexes,
|
||||||
|
name: Some(_),
|
||||||
|
} => "show index",
|
||||||
|
Self::ShowList { kind, .. } => kind.command_name(),
|
||||||
Self::Insert { .. } => "insert into",
|
Self::Insert { .. } => "insert into",
|
||||||
Self::Update { .. } => "update",
|
Self::Update { .. } => "update",
|
||||||
Self::Delete { .. } => "delete from",
|
Self::Delete { .. } => "delete from",
|
||||||
@@ -872,7 +940,7 @@ impl Command {
|
|||||||
Self::SqlDelete { .. } => "delete from",
|
Self::SqlDelete { .. } => "delete from",
|
||||||
Self::App(app) => match app {
|
Self::App(app) => match app {
|
||||||
AppCommand::Quit => "quit",
|
AppCommand::Quit => "quit",
|
||||||
AppCommand::Help => "help",
|
AppCommand::Help { .. } => "help",
|
||||||
AppCommand::Rebuild => "rebuild",
|
AppCommand::Rebuild => "rebuild",
|
||||||
AppCommand::Save => "save",
|
AppCommand::Save => "save",
|
||||||
AppCommand::SaveAs => "save as",
|
AppCommand::SaveAs => "save as",
|
||||||
@@ -948,6 +1016,10 @@ impl Command {
|
|||||||
// result renders as a data view, not a structure
|
// result renders as a data view, not a structure
|
||||||
// view, so an empty target is correct here.
|
// view, so an empty target is correct here.
|
||||||
Self::Select { .. } => "",
|
Self::Select { .. } => "",
|
||||||
|
// A `show <kind>` list spans every table (or none) —
|
||||||
|
// there is no single structure-target table; it renders
|
||||||
|
// as a list, not a structure view.
|
||||||
|
Self::ShowList { .. } => "",
|
||||||
// A SQL `INSERT` carries its parsed target table (for
|
// A SQL `INSERT` carries its parsed target table (for
|
||||||
// CSV re-persistence and ok-summary subject).
|
// CSV re-persistence and ok-summary subject).
|
||||||
Self::SqlInsert { target_table, .. }
|
Self::SqlInsert { target_table, .. }
|
||||||
@@ -970,11 +1042,26 @@ impl Command {
|
|||||||
match self {
|
match self {
|
||||||
Self::AddRelationship {
|
Self::AddRelationship {
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
child_table,
|
child_table,
|
||||||
child_column,
|
child_columns,
|
||||||
..
|
..
|
||||||
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
|
} => {
|
||||||
|
// `from P.col to C.col` (single) or `from P.(a, b) to
|
||||||
|
// C.(x, y)` (compound — ADR-0043), mirroring the DSL.
|
||||||
|
let fmt = |cols: &[String]| {
|
||||||
|
if cols.len() == 1 {
|
||||||
|
cols[0].clone()
|
||||||
|
} else {
|
||||||
|
format!("({})", cols.join(", "))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"from {parent_table}.{} to {child_table}.{}",
|
||||||
|
fmt(parent_columns),
|
||||||
|
fmt(child_columns),
|
||||||
|
)
|
||||||
|
}
|
||||||
Self::DropRelationship { selector } => match selector {
|
Self::DropRelationship { selector } => match selector {
|
||||||
RelationshipSelector::Named { name } => name.clone(),
|
RelationshipSelector::Named { name } => name.clone(),
|
||||||
RelationshipSelector::Endpoints {
|
RelationshipSelector::Endpoints {
|
||||||
|
|||||||
+15
-3
@@ -80,6 +80,11 @@ const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGE
|
|||||||
const EXPORT_PATH_OPT: Node = Node::Optional(&Node::BarePath);
|
const EXPORT_PATH_OPT: Node = Node::Optional(&Node::BarePath);
|
||||||
const IMPORT_BODY_OPT: Node = Node::Optional(&IMPORT_PATH_AND_TARGET);
|
const IMPORT_BODY_OPT: Node = Node::Optional(&IMPORT_PATH_AND_TARGET);
|
||||||
|
|
||||||
|
// `help [<topic>]` (H3): an optional single-word topic — a command
|
||||||
|
// entry word (`insert`, `create`, `show`, …) or `types`. Captured
|
||||||
|
// as a `BarePath` so any keyword-shaped word is accepted verbatim.
|
||||||
|
const HELP_TOPIC_OPT: Node = Node::Optional(&Node::BarePath);
|
||||||
|
|
||||||
// `mode <value>`: known keywords are surfaced as `Word` children
|
// `mode <value>`: known keywords are surfaced as `Word` children
|
||||||
// so they appear in the walker's expected set (and feed the
|
// so they appear in the walker's expected set (and feed the
|
||||||
// completion engine's keyword candidates). The trailing `Ident`
|
// completion engine's keyword candidates). The trailing `Ident`
|
||||||
@@ -154,8 +159,15 @@ const fn build_quit(_path: &MatchedPath, _source: &str) -> Result<Command, Valid
|
|||||||
Ok(Command::App(AppCommand::Quit))
|
Ok(Command::App(AppCommand::Quit))
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn build_help(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
fn build_help(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
Ok(Command::App(AppCommand::Help))
|
// Optional single-word topic (a command entry word, or
|
||||||
|
// `types`) captured as a `BarePath` — `help insert`,
|
||||||
|
// `help create`, `help show`. Multi-word commands share an
|
||||||
|
// entry word, so `help create` covers every create form.
|
||||||
|
let topic = path
|
||||||
|
.find(|i| matches!(i.kind, MatchedKind::BarePath))
|
||||||
|
.map(|i| i.text.clone());
|
||||||
|
Ok(Command::App(AppCommand::Help { topic }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||||
@@ -255,7 +267,7 @@ pub static QUIT: CommandNode = CommandNode {
|
|||||||
|
|
||||||
pub static HELP: CommandNode = CommandNode {
|
pub static HELP: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("help"),
|
entry: Word::keyword("help"),
|
||||||
shape: EMPTY_SEQ,
|
shape: HELP_TOPIC_OPT,
|
||||||
ast_builder: build_help,
|
ast_builder: build_help,
|
||||||
help_id: Some("app.help"),
|
help_id: Some("app.help"),
|
||||||
usage_ids: &["parse.usage.help"],};
|
usage_ids: &["parse.usage.help"],};
|
||||||
|
|||||||
+94
-6
@@ -24,7 +24,7 @@
|
|||||||
//! later swap that capture for the same typed slots used here, adding
|
//! later swap that capture for the same typed slots used here, adding
|
||||||
//! live hints/highlighting.
|
//! live hints/highlighting.
|
||||||
|
|
||||||
use crate::dsl::command::{Command, Expr, RowFilter};
|
use crate::dsl::command::{Command, Expr, RowFilter, ShowListKind};
|
||||||
use crate::dsl::grammar::{
|
use crate::dsl::grammar::{
|
||||||
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
|
||||||
shared::{
|
shared::{
|
||||||
@@ -99,7 +99,62 @@ const SHOW_TABLE_NODES: &[Node] = &[
|
|||||||
];
|
];
|
||||||
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
|
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
|
||||||
|
|
||||||
const SHOW_CHOICES: &[Node] = &[SHOW_DATA, SHOW_TABLE];
|
// `show tables` / `show relationships` / `show indexes` — the
|
||||||
|
// list-all forms (V5). Each is a single keyword with no argument;
|
||||||
|
// the executor lists every item of the kind. Distinct keyword
|
||||||
|
// tokens (`tables` ≠ `table`), so Choice ordering is irrelevant.
|
||||||
|
const SHOW_TABLES: Node = Node::Word(Word::keyword("tables"));
|
||||||
|
const SHOW_RELATIONSHIPS: Node = Node::Word(Word::keyword("relationships"));
|
||||||
|
const SHOW_INDEXES: Node = Node::Word(Word::keyword("indexes"));
|
||||||
|
|
||||||
|
// `show relationship <name>` / `show index <name>` — singular
|
||||||
|
// per-item detail (V5a). The name slot reuses the existing
|
||||||
|
// completion sources (relationship / index names). Distinct
|
||||||
|
// keyword tokens from the plurals (`relationship` ≠
|
||||||
|
// `relationships`), so Choice ordering is irrelevant.
|
||||||
|
const SHOW_RELATIONSHIP_NAME: Node = Node::Ident {
|
||||||
|
source: IdentSource::Relationships,
|
||||||
|
role: "relationship_name",
|
||||||
|
validator: None,
|
||||||
|
highlight_override: None,
|
||||||
|
writes_table: false,
|
||||||
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
|
writes_table_alias: false,
|
||||||
|
writes_cte_name: false,
|
||||||
|
writes_projection_alias: false,
|
||||||
|
};
|
||||||
|
const SHOW_RELATIONSHIP_NODES: &[Node] = &[
|
||||||
|
Node::Word(Word::keyword("relationship")),
|
||||||
|
SHOW_RELATIONSHIP_NAME,
|
||||||
|
];
|
||||||
|
const SHOW_RELATIONSHIP: Node = Node::Seq(SHOW_RELATIONSHIP_NODES);
|
||||||
|
|
||||||
|
const SHOW_INDEX_NAME: Node = Node::Ident {
|
||||||
|
source: IdentSource::Indexes,
|
||||||
|
role: "index_name",
|
||||||
|
validator: None,
|
||||||
|
highlight_override: None,
|
||||||
|
writes_table: false,
|
||||||
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
|
writes_table_alias: false,
|
||||||
|
writes_cte_name: false,
|
||||||
|
writes_projection_alias: false,
|
||||||
|
};
|
||||||
|
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] = &[
|
||||||
|
SHOW_DATA,
|
||||||
|
SHOW_TABLE,
|
||||||
|
SHOW_TABLES,
|
||||||
|
SHOW_RELATIONSHIPS,
|
||||||
|
SHOW_INDEXES,
|
||||||
|
SHOW_RELATIONSHIP,
|
||||||
|
SHOW_INDEX,
|
||||||
|
];
|
||||||
const SHOW_SHAPE: Node = Node::Choice(SHOW_CHOICES);
|
const SHOW_SHAPE: Node = Node::Choice(SHOW_CHOICES);
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -552,10 +607,35 @@ fn build_show(path: &MatchedPath, _source: &str) -> Result<Command, ValidationEr
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.nth(1);
|
.nth(1);
|
||||||
let name = require_ident(path, "table_name")?;
|
|
||||||
match sub {
|
match sub {
|
||||||
Some("data") => build_show_data(path, _source),
|
Some("data") => build_show_data(path, _source),
|
||||||
Some("table") => Ok(Command::ShowTable { name }),
|
// `name` is resolved only for the forms that carry one; the
|
||||||
|
// list-all forms (`tables` / `relationships` / `indexes`)
|
||||||
|
// have no table argument.
|
||||||
|
Some("table") => Ok(Command::ShowTable {
|
||||||
|
name: require_ident(path, "table_name")?,
|
||||||
|
}),
|
||||||
|
Some("tables") => Ok(Command::ShowList {
|
||||||
|
kind: ShowListKind::Tables,
|
||||||
|
name: None,
|
||||||
|
}),
|
||||||
|
Some("relationships") => Ok(Command::ShowList {
|
||||||
|
kind: ShowListKind::Relationships,
|
||||||
|
name: None,
|
||||||
|
}),
|
||||||
|
Some("indexes") => Ok(Command::ShowList {
|
||||||
|
kind: ShowListKind::Indexes,
|
||||||
|
name: None,
|
||||||
|
}),
|
||||||
|
// V5a singular per-item detail — carry the named item.
|
||||||
|
Some("relationship") => Ok(Command::ShowList {
|
||||||
|
kind: ShowListKind::Relationships,
|
||||||
|
name: Some(require_ident(path, "relationship_name")?),
|
||||||
|
}),
|
||||||
|
Some("index") => Ok(Command::ShowList {
|
||||||
|
kind: ShowListKind::Indexes,
|
||||||
|
name: Some(require_ident(path, "index_name")?),
|
||||||
|
}),
|
||||||
_ => Err(ValidationError {
|
_ => Err(ValidationError {
|
||||||
message_key: "parse.error_wrapper",
|
message_key: "parse.error_wrapper",
|
||||||
args: vec![("detail", "unknown show subcommand".to_string())],
|
args: vec![("detail", "unknown show subcommand".to_string())],
|
||||||
@@ -1362,7 +1442,15 @@ pub static SHOW: CommandNode = CommandNode {
|
|||||||
shape: SHOW_SHAPE,
|
shape: SHOW_SHAPE,
|
||||||
ast_builder: build_show,
|
ast_builder: build_show,
|
||||||
help_id: Some("data.show"),
|
help_id: Some("data.show"),
|
||||||
usage_ids: &["parse.usage.show_data", "parse.usage.show_table"],};
|
usage_ids: &[
|
||||||
|
"parse.usage.show_data",
|
||||||
|
"parse.usage.show_table",
|
||||||
|
"parse.usage.show_tables",
|
||||||
|
"parse.usage.show_relationships",
|
||||||
|
"parse.usage.show_indexes",
|
||||||
|
"parse.usage.show_relationship",
|
||||||
|
"parse.usage.show_index",
|
||||||
|
],};
|
||||||
|
|
||||||
pub static INSERT: CommandNode = CommandNode {
|
pub static INSERT: CommandNode = CommandNode {
|
||||||
entry: Word::keyword("insert"),
|
entry: Word::keyword("insert"),
|
||||||
@@ -1445,7 +1533,7 @@ pub static WITH: CommandNode = CommandNode {
|
|||||||
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||||
ast_builder: build_select,
|
ast_builder: build_select,
|
||||||
help_id: None,
|
help_id: None,
|
||||||
usage_ids: &["parse.usage.select"],};
|
usage_ids: &["parse.usage.with"],};
|
||||||
|
|
||||||
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
||||||
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
|
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
|
||||||
|
|||||||
+131
-54
@@ -385,6 +385,36 @@ const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES);
|
|||||||
// `writes_table: true` on each endpoint's table ident so the
|
// `writes_table: true` on each endpoint's table ident so the
|
||||||
// `.<col>` slot narrows to that table's columns (handoff-13
|
// `.<col>` slot narrows to that table's columns (handoff-13
|
||||||
// §2.2 follow-up — mirrors DR_PARENT / DR_CHILD).
|
// §2.2 follow-up — mirrors DR_PARENT / DR_CHILD).
|
||||||
|
// A single FK-endpoint column ident (narrows to the endpoint
|
||||||
|
// table's columns via the table ident's `writes_table: true`).
|
||||||
|
const AR_PARENT_COL: Node = Node::Ident {
|
||||||
|
source: IdentSource::Columns,
|
||||||
|
role: "parent_column",
|
||||||
|
validator: None,
|
||||||
|
highlight_override: None,
|
||||||
|
writes_table: false,
|
||||||
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
|
writes_table_alias: false,
|
||||||
|
writes_cte_name: false,
|
||||||
|
writes_projection_alias: false,
|
||||||
|
};
|
||||||
|
// Compound endpoint: `( a, b, … )` — a comma-separated column list
|
||||||
|
// in parens (ADR-0043). Same role as the single form, so the
|
||||||
|
// builder collects either shape uniformly.
|
||||||
|
const AR_PARENT_COL_LIST: Node = Node::Repeated {
|
||||||
|
inner: &AR_PARENT_COL,
|
||||||
|
separator: Some(&Node::Punct(',')),
|
||||||
|
min: 1,
|
||||||
|
};
|
||||||
|
const AR_PARENT_COLS_PAREN_NODES: &[Node] =
|
||||||
|
&[Node::Punct('('), AR_PARENT_COL_LIST, Node::Punct(')')];
|
||||||
|
const AR_PARENT_COLS_PAREN: Node = Node::Seq(AR_PARENT_COLS_PAREN_NODES);
|
||||||
|
// `from P.(a, b)` (compound) or `from P.col` (single) — Choice on
|
||||||
|
// the first post-`.` token (`(` vs an ident), so order is safe.
|
||||||
|
const AR_PARENT_COLS_CHOICES: &[Node] = &[AR_PARENT_COLS_PAREN, AR_PARENT_COL];
|
||||||
|
const AR_PARENT_COLS: Node = Node::Choice(AR_PARENT_COLS_CHOICES);
|
||||||
|
|
||||||
const AR_PARENT_NODES: &[Node] = &[
|
const AR_PARENT_NODES: &[Node] = &[
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
source: IdentSource::Tables,
|
source: IdentSource::Tables,
|
||||||
@@ -394,25 +424,37 @@ const AR_PARENT_NODES: &[Node] = &[
|
|||||||
writes_table: true,
|
writes_table: true,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
},
|
},
|
||||||
Node::Punct('.'),
|
Node::Punct('.'),
|
||||||
Node::Ident {
|
AR_PARENT_COLS,
|
||||||
source: IdentSource::Columns,
|
];
|
||||||
role: "parent_column",
|
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
|
||||||
validator: None,
|
|
||||||
highlight_override: None,
|
const AR_CHILD_COL: Node = Node::Ident {
|
||||||
writes_table: false,
|
source: IdentSource::Columns,
|
||||||
writes_column: false,
|
role: "child_column",
|
||||||
writes_user_listed_column: false,
|
validator: None,
|
||||||
|
highlight_override: None,
|
||||||
|
writes_table: false,
|
||||||
|
writes_column: false,
|
||||||
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
},
|
};
|
||||||
];
|
const AR_CHILD_COL_LIST: Node = Node::Repeated {
|
||||||
const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES);
|
inner: &AR_CHILD_COL,
|
||||||
|
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: 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);
|
||||||
|
|
||||||
const AR_CHILD_NODES: &[Node] = &[
|
const AR_CHILD_NODES: &[Node] = &[
|
||||||
Node::Ident {
|
Node::Ident {
|
||||||
@@ -423,23 +465,12 @@ const AR_CHILD_NODES: &[Node] = &[
|
|||||||
writes_table: true,
|
writes_table: true,
|
||||||
writes_column: false,
|
writes_column: false,
|
||||||
writes_user_listed_column: false,
|
writes_user_listed_column: false,
|
||||||
writes_table_alias: false,
|
writes_table_alias: false,
|
||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
},
|
},
|
||||||
Node::Punct('.'),
|
Node::Punct('.'),
|
||||||
Node::Ident {
|
AR_CHILD_COLS,
|
||||||
source: IdentSource::Columns,
|
|
||||||
role: "child_column",
|
|
||||||
validator: None,
|
|
||||||
highlight_override: None,
|
|
||||||
writes_table: false,
|
|
||||||
writes_column: false,
|
|
||||||
writes_user_listed_column: false,
|
|
||||||
writes_table_alias: false,
|
|
||||||
writes_cte_name: false,
|
|
||||||
writes_projection_alias: false,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
|
||||||
|
|
||||||
@@ -785,12 +816,24 @@ fn build_add_relationship(path: &MatchedPath, _source: &str) -> Result<Command,
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));
|
.any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk")));
|
||||||
|
|
||||||
|
// Collect every matched `parent_column` / `child_column` ident, in
|
||||||
|
// order — one each for the single-column `from P.col to C.col`
|
||||||
|
// form, or the full lists for the parenthesized compound form
|
||||||
|
// `from P.(a, b) to C.(x, y)` (ADR-0043).
|
||||||
|
let parent_columns = collect_idents(path, "parent_column");
|
||||||
|
let child_columns = collect_idents(path, "child_column");
|
||||||
|
if parent_columns.is_empty() || child_columns.is_empty() {
|
||||||
|
return Err(ValidationError {
|
||||||
|
message_key: "parse.error_wrapper",
|
||||||
|
args: vec![("detail", "a relationship needs both endpoints".to_string())],
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(Command::AddRelationship {
|
Ok(Command::AddRelationship {
|
||||||
name: ident(path, "relationship_name").map(str::to_string),
|
name: ident(path, "relationship_name").map(str::to_string),
|
||||||
parent_table: require_ident(path, "parent_table")?,
|
parent_table: require_ident(path, "parent_table")?,
|
||||||
parent_column: require_ident(path, "parent_column")?,
|
parent_columns,
|
||||||
child_table: require_ident(path, "child_table")?,
|
child_table: require_ident(path, "child_table")?,
|
||||||
child_column: require_ident(path, "child_column")?,
|
child_columns,
|
||||||
on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
|
on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action),
|
||||||
on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
|
on_update: on_update.unwrap_or_else(ReferentialAction::default_action),
|
||||||
create_fk,
|
create_fk,
|
||||||
@@ -1511,8 +1554,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
// consumed in `consume_fk_reference`, so they don't perturb
|
// consumed in `consume_fk_reference`, so they don't perturb
|
||||||
// the element-boundary `depth` tracker.
|
// the element-boundary `depth` tracker.
|
||||||
MatchedKind::Word("references") => {
|
MatchedKind::Word("references") => {
|
||||||
|
// 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());
|
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
|
||||||
foreign_keys.push(consume_fk_reference(&mut items, None, child_column));
|
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column]));
|
||||||
}
|
}
|
||||||
// Table-level `[constraint <name>] foreign key (<col>)
|
// Table-level `[constraint <name>] foreign key (<col>)
|
||||||
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
|
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
|
||||||
@@ -1520,11 +1565,21 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
|
||||||
items.next(); // `key`
|
items.next(); // `key`
|
||||||
}
|
}
|
||||||
// `( <child column> )`
|
// `( <child column> [, <child column>]* )` — a compound
|
||||||
|
// FK lists multiple child columns (ADR-0043).
|
||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
let child_column = items.next().map_or_else(String::new, |it| it.text.clone());
|
let mut child_columns = Vec::new();
|
||||||
|
while let Some(it) = items.peek() {
|
||||||
|
match &it.kind {
|
||||||
|
MatchedKind::Punct(')') => break,
|
||||||
|
MatchedKind::Punct(',') => {
|
||||||
|
items.next();
|
||||||
|
}
|
||||||
|
_ => child_columns.push(items.next().expect("peeked").text.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
@@ -1532,7 +1587,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
|||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
let fk = consume_fk_reference(&mut items, pending_fk_name.take(), child_column);
|
let fk = consume_fk_reference(&mut items, pending_fk_name.take(), child_columns);
|
||||||
foreign_keys.push(fk);
|
foreign_keys.push(fk);
|
||||||
}
|
}
|
||||||
// Track paren depth for element-boundary detection. The
|
// Track paren depth for element-boundary detection. The
|
||||||
@@ -1648,23 +1703,35 @@ where
|
|||||||
fn consume_fk_reference<'a, I>(
|
fn consume_fk_reference<'a, I>(
|
||||||
items: &mut std::iter::Peekable<I>,
|
items: &mut std::iter::Peekable<I>,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
child_column: String,
|
child_columns: Vec<String>,
|
||||||
) -> SqlForeignKey
|
) -> SqlForeignKey
|
||||||
where
|
where
|
||||||
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
|
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
|
||||||
{
|
{
|
||||||
let parent_table = items.next().map_or_else(String::new, |it| it.text.clone());
|
let parent_table = items.next().map_or_else(String::new, |it| it.text.clone());
|
||||||
// Optional `( <parent column> )`.
|
// Optional `( <parent column> [, <parent column>]* )` — a
|
||||||
let mut parent_column = None;
|
// compound FK references multiple parent columns (ADR-0043).
|
||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
// `None` for the bare `REFERENCES <parent>` form.
|
||||||
items.next(); // `(`
|
let parent_columns: Option<Vec<String>> =
|
||||||
if let Some(it) = items.next() {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||||||
parent_column = Some(it.text.clone());
|
items.next(); // `(`
|
||||||
}
|
let mut cols = Vec::new();
|
||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
while let Some(it) = items.peek() {
|
||||||
items.next(); // `)`
|
match &it.kind {
|
||||||
}
|
MatchedKind::Punct(')') => break,
|
||||||
}
|
MatchedKind::Punct(',') => {
|
||||||
|
items.next();
|
||||||
|
}
|
||||||
|
_ => cols.push(items.next().expect("peeked").text.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||||||
|
items.next(); // `)`
|
||||||
|
}
|
||||||
|
Some(cols)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
// `on <delete|update> <action>` clauses, in either order, 0..2.
|
// `on <delete|update> <action>` clauses, in either order, 0..2.
|
||||||
let mut on_delete = ReferentialAction::default_action();
|
let mut on_delete = ReferentialAction::default_action();
|
||||||
let mut on_update = ReferentialAction::default_action();
|
let mut on_update = ReferentialAction::default_action();
|
||||||
@@ -1680,9 +1747,9 @@ where
|
|||||||
}
|
}
|
||||||
SqlForeignKey {
|
SqlForeignKey {
|
||||||
name,
|
name,
|
||||||
child_column,
|
child_columns,
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
on_delete,
|
on_delete,
|
||||||
on_update,
|
on_update,
|
||||||
}
|
}
|
||||||
@@ -2370,14 +2437,24 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
|
|||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
|
||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
let child_column = items.next().map_or_else(String::new, |it| it.text.clone());
|
// `( <child column> [, <child column>]* )` — compound FK (ADR-0043).
|
||||||
|
let mut child_columns = Vec::new();
|
||||||
|
while let Some(it) = items.peek() {
|
||||||
|
match &it.kind {
|
||||||
|
MatchedKind::Punct(')') => break,
|
||||||
|
MatchedKind::Punct(',') => {
|
||||||
|
items.next();
|
||||||
|
}
|
||||||
|
_ => child_columns.push(items.next().expect("peeked").text.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
|
||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||||||
items.next();
|
items.next();
|
||||||
}
|
}
|
||||||
consume_fk_reference(&mut items, None, child_column)
|
consume_fk_reference(&mut items, None, child_columns)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||||||
@@ -3202,9 +3279,9 @@ mod sql_alter_table_tests {
|
|||||||
assert_eq!(name, None);
|
assert_eq!(name, None);
|
||||||
match *constraint {
|
match *constraint {
|
||||||
TableConstraint::ForeignKey(fk) => {
|
TableConstraint::ForeignKey(fk) => {
|
||||||
assert_eq!(fk.child_column, "pid");
|
assert_eq!(fk.child_columns, vec!["pid".to_string()]);
|
||||||
assert_eq!(fk.parent_table, "P");
|
assert_eq!(fk.parent_table, "P");
|
||||||
assert_eq!(fk.parent_column.as_deref(), Some("id"));
|
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
|
||||||
}
|
}
|
||||||
other => panic!("expected ForeignKey, got {other:?}"),
|
other => panic!("expected ForeignKey, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -3216,7 +3293,7 @@ mod sql_alter_table_tests {
|
|||||||
assert_eq!(name.as_deref(), Some("fk_p"));
|
assert_eq!(name.as_deref(), Some("fk_p"));
|
||||||
match *constraint {
|
match *constraint {
|
||||||
TableConstraint::ForeignKey(fk) => {
|
TableConstraint::ForeignKey(fk) => {
|
||||||
assert_eq!(fk.parent_column, None, "bare reference resolves at execution");
|
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
|
||||||
}
|
}
|
||||||
other => panic!("expected ForeignKey, got {other:?}"),
|
other => panic!("expected ForeignKey, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-4
@@ -533,13 +533,89 @@ pub struct CommandNode {
|
|||||||
/// Returns the canonical (primary-form) entry literal and the
|
/// Returns the canonical (primary-form) entry literal and the
|
||||||
/// `usage_ids` list, or `None` if no entry word matches.
|
/// `usage_ids` list, or `None` if no entry word matches.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, &'static [&'static str])> {
|
pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, Vec<&'static str>)> {
|
||||||
|
usage_keys_for_input_in_mode(source, crate::mode::Mode::Simple)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode-aware variant of [`usage_keys_for_input`] (ADR-0042 G3).
|
||||||
|
///
|
||||||
|
/// A shared entry word (`create`, `drop`, `insert`, …) registers a
|
||||||
|
/// `Simple` DSL node *and* one or more `Advanced` SQL nodes. The
|
||||||
|
/// usage block must reflect the surface the user is actually typing:
|
||||||
|
/// the SQL forms in `Advanced` mode, the DSL forms in `Simple` mode
|
||||||
|
/// — otherwise advanced-mode `create` shows the DSL `create table …
|
||||||
|
/// with pk …` template, which is not valid SQL.
|
||||||
|
///
|
||||||
|
/// Selection prefers candidates whose [`CommandCategory`] matches
|
||||||
|
/// the mode; if the entry word has none in that category (an
|
||||||
|
/// app-lifecycle command is `Simple`-only yet usable in both modes),
|
||||||
|
/// every candidate is used. The returned keys are the union of the
|
||||||
|
/// selected nodes' `usage_ids`, de-duplicated in registry order — so
|
||||||
|
/// advanced `create` shows both `sql_create_table` and
|
||||||
|
/// `sql_create_index`.
|
||||||
|
#[must_use]
|
||||||
|
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};
|
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||||
let start = skip_whitespace(source, 0);
|
let start = skip_whitespace(source, 0);
|
||||||
let (kw_start, kw_end) = consume_ident(source, start)?;
|
let (kw_start, kw_end) = consume_ident(source, start)?;
|
||||||
let word = &source[kw_start..kw_end];
|
let word = &source[kw_start..kw_end];
|
||||||
let (_, node) = command_for_entry_word(word)?;
|
let candidates = commands_for_entry_word(word);
|
||||||
Some((node.entry.primary, node.usage_ids))
|
if candidates.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 k in node.usage_ids {
|
||||||
|
if !keys.contains(k) {
|
||||||
|
keys.push(*k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys
|
||||||
|
};
|
||||||
|
// 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 selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
|
||||||
|
if mode == crate::mode::Mode::Advanced {
|
||||||
|
let mut v: Vec<_> = candidates
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|(_, _, c)| *c == CommandCategory::Advanced)
|
||||||
|
.collect();
|
||||||
|
v.extend(
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|(_, _, c)| *c != CommandCategory::Advanced),
|
||||||
|
);
|
||||||
|
v
|
||||||
|
} else {
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
let entry = pick[0].1.entry.primary;
|
||||||
|
Some((entry, keys))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single usage template most relevant to `source`, when
|
/// The single usage template most relevant to `source`, when
|
||||||
@@ -555,8 +631,19 @@ pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, &'static [&'s
|
|||||||
/// show the whole family or nothing.
|
/// show the whole family or nothing.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
|
pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
|
||||||
|
usage_key_for_input_in_mode(source, crate::mode::Mode::Simple)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode-aware variant of [`usage_key_for_input`] (ADR-0042 G3) —
|
||||||
|
/// 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};
|
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||||
let (_entry, keys) = usage_keys_for_input(source)?;
|
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
||||||
let first = *keys.first()?;
|
let first = *keys.first()?;
|
||||||
if keys.len() == 1 {
|
if keys.len() == 1 {
|
||||||
return Some(first);
|
return Some(first);
|
||||||
|
|||||||
@@ -182,7 +182,15 @@ const FK_PARENT_COLUMN: Node = Node::Ident {
|
|||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
static FK_PARENT_COL_NODES: &[Node] = &[Node::Punct('('), FK_PARENT_COLUMN, Node::Punct(')')];
|
// `( a [, b]* )` — a compound FK references multiple parent columns
|
||||||
|
// (ADR-0043). The `Repeated` separator handles the commas; a
|
||||||
|
// single-column FK is the one-element case.
|
||||||
|
const FK_PARENT_COL_LIST: Node = Node::Repeated {
|
||||||
|
inner: &FK_PARENT_COLUMN,
|
||||||
|
separator: Some(&Node::Punct(',')),
|
||||||
|
min: 1,
|
||||||
|
};
|
||||||
|
static FK_PARENT_COL_NODES: &[Node] = &[Node::Punct('('), FK_PARENT_COL_LIST, Node::Punct(')')];
|
||||||
const FK_PARENT_COL_OPT: Node = Node::Optional(&Node::Seq(FK_PARENT_COL_NODES));
|
const FK_PARENT_COL_OPT: Node = Node::Optional(&Node::Seq(FK_PARENT_COL_NODES));
|
||||||
|
|
||||||
// `REFERENCES <parent> [ ( <col> ) ] [on delete/update …]` — the inline
|
// `REFERENCES <parent> [ ( <col> ) ] [on delete/update …]` — the inline
|
||||||
@@ -333,6 +341,13 @@ const FK_CHILD_COLUMN: Node = Node::Ident {
|
|||||||
writes_cte_name: false,
|
writes_cte_name: false,
|
||||||
writes_projection_alias: false,
|
writes_projection_alias: false,
|
||||||
};
|
};
|
||||||
|
// `( a [, b]* )` — a compound FK lists multiple child columns
|
||||||
|
// (ADR-0043); single-column is the one-element case.
|
||||||
|
const FK_CHILD_COL_LIST: Node = Node::Repeated {
|
||||||
|
inner: &FK_CHILD_COLUMN,
|
||||||
|
separator: Some(&Node::Punct(',')),
|
||||||
|
min: 1,
|
||||||
|
};
|
||||||
const FK_NAME: Node = Node::Ident {
|
const FK_NAME: Node = Node::Ident {
|
||||||
source: IdentSource::NewName,
|
source: IdentSource::NewName,
|
||||||
role: "fk_name",
|
role: "fk_name",
|
||||||
@@ -354,7 +369,7 @@ static FOREIGN_KEY_BODY_NODES: &[Node] = &[
|
|||||||
Node::Word(Word::keyword("foreign")),
|
Node::Word(Word::keyword("foreign")),
|
||||||
Node::Word(Word::keyword("key")),
|
Node::Word(Word::keyword("key")),
|
||||||
Node::Punct('('),
|
Node::Punct('('),
|
||||||
FK_CHILD_COLUMN,
|
FK_CHILD_COL_LIST,
|
||||||
Node::Punct(')'),
|
Node::Punct(')'),
|
||||||
Node::Word(Word::keyword("references")),
|
Node::Word(Word::keyword("references")),
|
||||||
FK_PARENT_TABLE,
|
FK_PARENT_TABLE,
|
||||||
@@ -374,7 +389,7 @@ static TABLE_FK_NAMED_NODES: &[Node] = &[
|
|||||||
Node::Word(Word::keyword("foreign")),
|
Node::Word(Word::keyword("foreign")),
|
||||||
Node::Word(Word::keyword("key")),
|
Node::Word(Word::keyword("key")),
|
||||||
Node::Punct('('),
|
Node::Punct('('),
|
||||||
FK_CHILD_COLUMN,
|
FK_CHILD_COL_LIST,
|
||||||
Node::Punct(')'),
|
Node::Punct(')'),
|
||||||
Node::Word(Word::keyword("references")),
|
Node::Word(Word::keyword("references")),
|
||||||
FK_PARENT_TABLE,
|
FK_PARENT_TABLE,
|
||||||
@@ -984,9 +999,9 @@ mod builder_tests {
|
|||||||
assert_eq!(fks.len(), 1);
|
assert_eq!(fks.len(), 1);
|
||||||
let fk = &fks[0];
|
let fk = &fks[0];
|
||||||
assert_eq!(fk.name, None, "inline FK is auto-named at execution");
|
assert_eq!(fk.name, None, "inline FK is auto-named at execution");
|
||||||
assert_eq!(fk.child_column, "pid");
|
assert_eq!(fk.child_columns, vec!["pid".to_string()]);
|
||||||
assert_eq!(fk.parent_table, "parent");
|
assert_eq!(fk.parent_table, "parent");
|
||||||
assert_eq!(fk.parent_column.as_deref(), Some("id"));
|
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
|
||||||
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
|
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
|
||||||
assert_eq!(fk.on_update, ReferentialAction::NoAction);
|
assert_eq!(fk.on_update, ReferentialAction::NoAction);
|
||||||
}
|
}
|
||||||
@@ -994,9 +1009,9 @@ mod builder_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bare_inline_reference_has_no_parent_column() {
|
fn bare_inline_reference_has_no_parent_column() {
|
||||||
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
|
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
|
||||||
assert_eq!(fks[0].parent_column, None, "bare REFERENCES — resolved at execution");
|
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
|
||||||
assert_eq!(fks[0].parent_table, "parent");
|
assert_eq!(fks[0].parent_table, "parent");
|
||||||
assert_eq!(fks[0].child_column, "pid");
|
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1026,9 +1041,9 @@ mod builder_tests {
|
|||||||
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
|
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
|
||||||
assert_eq!(fks.len(), 1);
|
assert_eq!(fks.len(), 1);
|
||||||
assert_eq!(fks[0].name, None);
|
assert_eq!(fks[0].name, None);
|
||||||
assert_eq!(fks[0].child_column, "pid");
|
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
||||||
assert_eq!(fks[0].parent_table, "parent");
|
assert_eq!(fks[0].parent_table, "parent");
|
||||||
assert_eq!(fks[0].parent_column.as_deref(), Some("id"));
|
assert_eq!(fks[0].parent_columns, Some(vec!["id".to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1038,7 +1053,7 @@ mod builder_tests {
|
|||||||
constraint fk_parent foreign key (pid) references parent(id))",
|
constraint fk_parent foreign key (pid) references parent(id))",
|
||||||
);
|
);
|
||||||
assert_eq!(fks[0].name.as_deref(), Some("fk_parent"));
|
assert_eq!(fks[0].name.as_deref(), Some("fk_parent"));
|
||||||
assert_eq!(fks[0].child_column, "pid");
|
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1048,8 +1063,8 @@ mod builder_tests {
|
|||||||
foreign key (a) references p(id), foreign key (b) references q(id))",
|
foreign key (a) references p(id), foreign key (b) references q(id))",
|
||||||
);
|
);
|
||||||
assert_eq!(fks.len(), 2);
|
assert_eq!(fks.len(), 2);
|
||||||
assert_eq!((fks[0].child_column.as_str(), fks[0].parent_table.as_str()), ("a", "p"));
|
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
|
||||||
assert_eq!((fks[1].child_column.as_str(), fks[1].parent_table.as_str()), ("b", "q"));
|
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1057,8 +1072,8 @@ mod builder_tests {
|
|||||||
let fks =
|
let fks =
|
||||||
parse_sct_fks("create table emp (id int primary key, mgr int references emp(id))");
|
parse_sct_fks("create table emp (id int primary key, mgr int references emp(id))");
|
||||||
assert_eq!(fks[0].parent_table, "emp", "self-reference");
|
assert_eq!(fks[0].parent_table, "emp", "self-reference");
|
||||||
assert_eq!(fks[0].child_column, "mgr");
|
assert_eq!(fks[0].child_columns, vec!["mgr".to_string()]);
|
||||||
assert_eq!(fks[0].parent_column.as_deref(), Some("id"));
|
assert_eq!(fks[0].parent_columns, Some(vec!["id".to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1080,7 +1095,7 @@ mod builder_tests {
|
|||||||
} => {
|
} => {
|
||||||
assert_eq!(primary_key, vec!["id".to_string()]);
|
assert_eq!(primary_key, vec!["id".to_string()]);
|
||||||
assert_eq!(foreign_keys.len(), 1);
|
assert_eq!(foreign_keys.len(), 1);
|
||||||
assert_eq!(foreign_keys[0].child_column, "pid");
|
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
|
||||||
// the column-level CHECK still attaches to `pid`
|
// the column-level CHECK still attaches to `pid`
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
|
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ pub use action::ReferentialAction;
|
|||||||
pub use command::{
|
pub use command::{
|
||||||
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
|
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
|
||||||
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
|
||||||
SqlForeignKey,
|
ShowListKind, SqlForeignKey,
|
||||||
};
|
};
|
||||||
pub use parser::{ParseError, parse_command};
|
pub use parser::{ParseError, parse_command};
|
||||||
pub use types::Type;
|
pub use types::Type;
|
||||||
|
|||||||
+79
-4
@@ -263,6 +263,16 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
|
|||||||
use crate::dsl::grammar::IdentSource;
|
use crate::dsl::grammar::IdentSource;
|
||||||
use crate::dsl::walker::outcome::Expectation;
|
use crate::dsl::walker::outcome::Expectation;
|
||||||
match e {
|
match e {
|
||||||
|
// ADR-0042 G1: the bare `1` that opens `add 1:n
|
||||||
|
// relationship …` is the project's only `Literal("1")`
|
||||||
|
// (grammar `ddl.rs`); on its own in an expected-set it is
|
||||||
|
// cryptic — a learner cannot know it begins a
|
||||||
|
// relationship. Render it as the named construct in error
|
||||||
|
// wording. This is render-only: completion/hints read the
|
||||||
|
// raw `Expectation::Literal("1")` directly (offering the
|
||||||
|
// literal `1` to type), so the candidate surface is
|
||||||
|
// unchanged.
|
||||||
|
Expectation::Literal("1") => "`1:n relationship`".to_string(),
|
||||||
Expectation::Word(w) | Expectation::Literal(w) => format!("`{w}`"),
|
Expectation::Word(w) | Expectation::Literal(w) => format!("`{w}`"),
|
||||||
Expectation::Ident { source, .. } => match source {
|
Expectation::Ident { source, .. } => match source {
|
||||||
// Match `IdentSlot::expected_label` outputs so the
|
// Match `IdentSlot::expected_label` outputs so the
|
||||||
@@ -286,14 +296,79 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ADR-0042 G2: a projection start (`select |`, or the projection
|
||||||
|
/// position inside a subquery / CTE body) expects the full
|
||||||
|
/// expression first-set — 14 alternatives — plus the SELECT
|
||||||
|
/// quantifiers `distinct` and `all`. Those two quantifiers are
|
||||||
|
/// jointly expectable *only* at a projection start, so their joint
|
||||||
|
/// presence is a precise signature for collapsing the noisy list
|
||||||
|
/// into one gloss. Render-only: this fires inside
|
||||||
|
/// `format_walker_error` (the error message), not in the expected
|
||||||
|
/// set the completion/hint layer consumes.
|
||||||
|
fn is_select_projection_start(expected: &[crate::dsl::walker::outcome::Expectation]) -> bool {
|
||||||
|
use crate::dsl::walker::outcome::Expectation;
|
||||||
|
let has_word = |w: &str| {
|
||||||
|
expected
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, Expectation::Word(x) if x.eq_ignore_ascii_case(w)))
|
||||||
|
};
|
||||||
|
has_word("distinct") && has_word("all")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0042 §3: detect the `… CROSS JOIN <table> on …` mistake. A
|
||||||
|
/// CROSS JOIN takes no `ON` clause; the grammar rejects the `on`,
|
||||||
|
/// but the bare structural error ("expected end of input") doesn't
|
||||||
|
/// teach why. `on` is unexpected at this position *only* when the
|
||||||
|
/// most recent join is a CROSS join — every other join flavour
|
||||||
|
/// requires `on`, so there `on` would be in the expected set, not a
|
||||||
|
/// failure. Detection: the failing token is the keyword `on`, and
|
||||||
|
/// the last `join` word in the consumed prefix is immediately
|
||||||
|
/// preceded by `cross`. Render-only; no grammar change.
|
||||||
|
fn is_cross_join_on(source: &str, position: usize) -> bool {
|
||||||
|
let rest = source[position.min(source.len())..].trim_start();
|
||||||
|
let next_is_on = {
|
||||||
|
let mut chars = rest.chars();
|
||||||
|
let starts_on = rest.len() >= 2 && rest[..2].eq_ignore_ascii_case("on");
|
||||||
|
let boundary = chars
|
||||||
|
.nth(2)
|
||||||
|
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
|
||||||
|
starts_on && boundary
|
||||||
|
};
|
||||||
|
if !next_is_on {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let consumed = &source[..position.min(source.len())];
|
||||||
|
let words: Vec<&str> = consumed
|
||||||
|
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.collect();
|
||||||
|
match words.iter().rposition(|w| w.eq_ignore_ascii_case("join")) {
|
||||||
|
Some(i) if i > 0 => words[i - 1].eq_ignore_ascii_case("cross"),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_walker_error(
|
fn format_walker_error(
|
||||||
source: &str,
|
source: &str,
|
||||||
position: usize,
|
position: usize,
|
||||||
at_eof: bool,
|
at_eof: bool,
|
||||||
expected: &[crate::dsl::walker::outcome::Expectation],
|
expected: &[crate::dsl::walker::outcome::Expectation],
|
||||||
) -> String {
|
) -> String {
|
||||||
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
|
if is_cross_join_on(source, position) {
|
||||||
let joined = oxford_join(&parts);
|
let consumed = source[..position.min(source.len())].trim_end();
|
||||||
|
let prefix = if consumed.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("after `{consumed}`, ")
|
||||||
|
};
|
||||||
|
return format!("{prefix}{}", crate::t!("parse.cross_join_no_on"));
|
||||||
|
}
|
||||||
|
let joined = if is_select_projection_start(expected) {
|
||||||
|
crate::t!("parse.expect.select_projection")
|
||||||
|
} else {
|
||||||
|
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
|
||||||
|
oxford_join(&parts)
|
||||||
|
};
|
||||||
|
|
||||||
// Mirror the chumsky-side wording: "after `<consumed>`,
|
// Mirror the chumsky-side wording: "after `<consumed>`,
|
||||||
// expected …" when the parser already consumed something
|
// expected …" when the parser already consumed something
|
||||||
@@ -862,9 +937,9 @@ mod tests {
|
|||||||
Command::AddRelationship {
|
Command::AddRelationship {
|
||||||
name: name.map(String::from),
|
name: name.map(String::from),
|
||||||
parent_table: parent.0.to_string(),
|
parent_table: parent.0.to_string(),
|
||||||
parent_column: parent.1.to_string(),
|
parent_columns: vec![parent.1.to_string()],
|
||||||
child_table: child.0.to_string(),
|
child_table: child.0.to_string(),
|
||||||
child_column: child.1.to_string(),
|
child_columns: vec![child.1.to_string()],
|
||||||
on_delete,
|
on_delete,
|
||||||
on_update,
|
on_update,
|
||||||
create_fk,
|
create_fk,
|
||||||
|
|||||||
@@ -3088,7 +3088,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn walker_parses_help() {
|
fn walker_parses_help() {
|
||||||
assert_eq!(parse("help").unwrap(), Command::App(AppCommand::Help));
|
assert_eq!(parse("help").unwrap(), Command::App(AppCommand::Help { topic: None }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3514,9 +3514,9 @@ mod tests {
|
|||||||
Command::AddRelationship {
|
Command::AddRelationship {
|
||||||
name: None,
|
name: None,
|
||||||
parent_table: "Customers".to_string(),
|
parent_table: "Customers".to_string(),
|
||||||
parent_column: "id".to_string(),
|
parent_columns: vec!["id".to_string()],
|
||||||
child_table: "Orders".to_string(),
|
child_table: "Orders".to_string(),
|
||||||
child_column: "customer_id".to_string(),
|
child_columns: vec!["customer_id".to_string()],
|
||||||
on_delete: ReferentialAction::default_action(),
|
on_delete: ReferentialAction::default_action(),
|
||||||
on_update: ReferentialAction::default_action(),
|
on_update: ReferentialAction::default_action(),
|
||||||
create_fk: false,
|
create_fk: false,
|
||||||
@@ -3535,9 +3535,9 @@ mod tests {
|
|||||||
Command::AddRelationship {
|
Command::AddRelationship {
|
||||||
name: Some("cust_orders".to_string()),
|
name: Some("cust_orders".to_string()),
|
||||||
parent_table: "Customers".to_string(),
|
parent_table: "Customers".to_string(),
|
||||||
parent_column: "id".to_string(),
|
parent_columns: vec!["id".to_string()],
|
||||||
child_table: "Orders".to_string(),
|
child_table: "Orders".to_string(),
|
||||||
child_column: "customer_id".to_string(),
|
child_columns: vec!["customer_id".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::SetNull,
|
on_update: ReferentialAction::SetNull,
|
||||||
create_fk: true,
|
create_fk: true,
|
||||||
@@ -6644,7 +6644,7 @@ mod dispatch_3a_tests {
|
|||||||
// Distinct dummy commands so a test can tell which node a walk
|
// Distinct dummy commands so a test can tell which node a walk
|
||||||
// committed to (the outcome alone doesn't distinguish them).
|
// committed to (the outcome alone doesn't distinguish them).
|
||||||
fn dsl_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
|
fn dsl_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
|
||||||
Ok(Command::App(AppCommand::Help))
|
Ok(Command::App(AppCommand::Help { topic: None }))
|
||||||
}
|
}
|
||||||
fn sql_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
|
fn sql_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
|
||||||
Ok(Command::App(AppCommand::Quit))
|
Ok(Command::App(AppCommand::Quit))
|
||||||
@@ -6729,7 +6729,7 @@ mod dispatch_3a_tests {
|
|||||||
);
|
);
|
||||||
let (outcome, cmd) = dispatch("smk dsltail", Mode::Simple, &cands);
|
let (outcome, cmd) = dispatch("smk dsltail", Mode::Simple, &cands);
|
||||||
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}");
|
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}");
|
||||||
assert_eq!(cmd, Some(Command::App(AppCommand::Help)));
|
assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Exit-gate case 2: Advanced + SQL input → SQL match ----
|
// ---- Exit-gate case 2: Advanced + SQL input → SQL match ----
|
||||||
@@ -6805,7 +6805,7 @@ mod dispatch_3a_tests {
|
|||||||
);
|
);
|
||||||
let (outcome, cmd) = dispatch("smk dsltail", Mode::Advanced, &cands);
|
let (outcome, cmd) = dispatch("smk dsltail", Mode::Advanced, &cands);
|
||||||
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}");
|
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}");
|
||||||
assert_eq!(cmd, Some(Command::App(AppCommand::Help)));
|
assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None })));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In advanced mode a non-shared DSL entry word (no Advanced
|
/// In advanced mode a non-shared DSL entry word (no Advanced
|
||||||
|
|||||||
+38
-31
@@ -264,12 +264,16 @@ pub(crate) fn render_drop_index(name: &str) -> String {
|
|||||||
pub(crate) fn render_add_relationship(
|
pub(crate) fn render_add_relationship(
|
||||||
name: &str,
|
name: &str,
|
||||||
parent_table: &str,
|
parent_table: &str,
|
||||||
parent_column: &str,
|
parent_columns: &[String],
|
||||||
child_table: &str,
|
child_table: &str,
|
||||||
child_column: &str,
|
child_columns: &[String],
|
||||||
on_delete: ReferentialAction,
|
on_delete: ReferentialAction,
|
||||||
on_update: ReferentialAction,
|
on_update: ReferentialAction,
|
||||||
) -> String {
|
) -> String {
|
||||||
|
// Multi-column FK (ADR-0043): comma-join each side; a
|
||||||
|
// single-column FK is the one-element case.
|
||||||
|
let child_column = child_columns.join(", ");
|
||||||
|
let parent_column = parent_columns.join(", ");
|
||||||
let mut s = format!(
|
let mut s = format!(
|
||||||
"ALTER TABLE {child_table} ADD CONSTRAINT {name} FOREIGN KEY ({child_column}) REFERENCES {parent_table} ({parent_column})"
|
"ALTER TABLE {child_table} ADD CONSTRAINT {name} FOREIGN KEY ({child_column}) REFERENCES {parent_table} ({parent_column})"
|
||||||
);
|
);
|
||||||
@@ -325,28 +329,31 @@ pub(crate) fn render_drop_column_cascade(
|
|||||||
pub(crate) fn render_add_relationship_create_fk(
|
pub(crate) fn render_add_relationship_create_fk(
|
||||||
name: &str,
|
name: &str,
|
||||||
parent_table: &str,
|
parent_table: &str,
|
||||||
parent_column: &str,
|
parent_columns: &[String],
|
||||||
child_table: &str,
|
child_table: &str,
|
||||||
child_column: &str,
|
child_columns: &[String],
|
||||||
on_delete: ReferentialAction,
|
on_delete: ReferentialAction,
|
||||||
on_update: ReferentialAction,
|
on_update: ReferentialAction,
|
||||||
new_child_column_type: crate::dsl::types::Type,
|
// The child columns `--create-fk` newly creates, with their types
|
||||||
|
// (ADR-0043: one per missing column, typed to the matching parent
|
||||||
|
// PK column's `fk_target_type`). Columns that already existed are
|
||||||
|
// omitted — no `ADD COLUMN` line for them.
|
||||||
|
new_columns: &[(String, crate::dsl::types::Type)],
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
vec![
|
let mut lines: Vec<String> = new_columns
|
||||||
format!(
|
.iter()
|
||||||
"ALTER TABLE {child_table} ADD COLUMN {child_column} {}",
|
.map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword()))
|
||||||
new_child_column_type.keyword()
|
.collect();
|
||||||
),
|
lines.push(render_add_relationship(
|
||||||
render_add_relationship(
|
name,
|
||||||
name,
|
parent_table,
|
||||||
parent_table,
|
parent_columns,
|
||||||
parent_column,
|
child_table,
|
||||||
child_table,
|
child_columns,
|
||||||
child_column,
|
on_delete,
|
||||||
on_delete,
|
on_update,
|
||||||
on_update,
|
));
|
||||||
),
|
lines
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append the `NOT NULL` / `UNIQUE` / `DEFAULT` / `CHECK` column-constraint
|
/// Append the `NOT NULL` / `UNIQUE` / `DEFAULT` / `CHECK` column-constraint
|
||||||
@@ -953,9 +960,9 @@ mod tests {
|
|||||||
let sql = render_add_relationship(
|
let sql = render_add_relationship(
|
||||||
"Orders_CustId_to_Customers_id",
|
"Orders_CustId_to_Customers_id",
|
||||||
"Customers",
|
"Customers",
|
||||||
"id",
|
&["id".to_string()],
|
||||||
"Orders",
|
"Orders",
|
||||||
"CustId",
|
&["CustId".to_string()],
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
);
|
);
|
||||||
@@ -971,9 +978,9 @@ mod tests {
|
|||||||
let sql = render_add_relationship(
|
let sql = render_add_relationship(
|
||||||
"places",
|
"places",
|
||||||
"Customers",
|
"Customers",
|
||||||
"id",
|
&["id".to_string()],
|
||||||
"Orders",
|
"Orders",
|
||||||
"CustId",
|
&["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::SetNull,
|
ReferentialAction::SetNull,
|
||||||
);
|
);
|
||||||
@@ -1029,14 +1036,14 @@ mod tests {
|
|||||||
let lines = render_add_relationship_create_fk(
|
let lines = render_add_relationship_create_fk(
|
||||||
"Customers_id_to_Orders_CustId",
|
"Customers_id_to_Orders_CustId",
|
||||||
"Customers",
|
"Customers",
|
||||||
"id",
|
&["id".to_string()],
|
||||||
"Orders",
|
"Orders",
|
||||||
"CustId",
|
&["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
// Parent PK is `serial` → child FK column is `int`
|
// Parent PK is `serial` → child FK column is `int`
|
||||||
// (`Type::fk_target_type` strips auto-gen semantics; ADR-0011).
|
// (`Type::fk_target_type` strips auto-gen semantics; ADR-0011).
|
||||||
crate::dsl::types::Type::Int,
|
&[("CustId".to_string(), crate::dsl::types::Type::Int)],
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lines.as_slice(),
|
lines.as_slice(),
|
||||||
@@ -1055,12 +1062,12 @@ mod tests {
|
|||||||
let lines = render_add_relationship_create_fk(
|
let lines = render_add_relationship_create_fk(
|
||||||
"Items_code_to_Lines_code",
|
"Items_code_to_Lines_code",
|
||||||
"Items",
|
"Items",
|
||||||
"code",
|
&["code".to_string()],
|
||||||
"Lines",
|
"Lines",
|
||||||
"code",
|
&["code".to_string()],
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
crate::dsl::types::Type::Text,
|
&[("code".to_string(), crate::dsl::types::Type::Text)],
|
||||||
);
|
);
|
||||||
assert_eq!(lines[0], "ALTER TABLE Lines ADD COLUMN code text");
|
assert_eq!(lines[0], "ALTER TABLE Lines ADD COLUMN code text");
|
||||||
// No referential clauses when both default.
|
// No referential clauses when both default.
|
||||||
@@ -1181,7 +1188,7 @@ mod tests {
|
|||||||
// advanced` (verb + payload).
|
// advanced` (verb + payload).
|
||||||
for app in [
|
for app in [
|
||||||
AppCommand::Quit,
|
AppCommand::Quit,
|
||||||
AppCommand::Help,
|
AppCommand::Help { topic: None },
|
||||||
AppCommand::Rebuild,
|
AppCommand::Rebuild,
|
||||||
AppCommand::Save,
|
AppCommand::Save,
|
||||||
AppCommand::New,
|
AppCommand::New,
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ pub enum AppEvent {
|
|||||||
/// An `explain …` command succeeded (ADR-0028). `plan`
|
/// An `explain …` command succeeded (ADR-0028). `plan`
|
||||||
/// carries the captured query plan; nothing was executed.
|
/// carries the captured query plan; nothing was executed.
|
||||||
DslExplainSucceeded { command: Command, plan: QueryPlan },
|
DslExplainSucceeded { command: Command, plan: QueryPlan },
|
||||||
|
/// A `show <kind>` list command (V5) — carries pre-formatted
|
||||||
|
/// display lines (tables / relationships / indexes).
|
||||||
|
DslShowListSucceeded { command: Command, lines: Vec<String> },
|
||||||
DslInsertSucceeded {
|
DslInsertSucceeded {
|
||||||
command: Command,
|
command: Command,
|
||||||
result: InsertResult,
|
result: InsertResult,
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("help.intro", &[]),
|
("help.intro", &[]),
|
||||||
("help.dsl_section", &[]),
|
("help.dsl_section", &[]),
|
||||||
("help.types_reference", &[]),
|
("help.types_reference", &[]),
|
||||||
|
("help.detail_hint", &[]),
|
||||||
|
("help.unknown_topic", &["topic"]),
|
||||||
("help.app.quit", &[]),
|
("help.app.quit", &[]),
|
||||||
("help.app.help", &[]),
|
("help.app.help", &[]),
|
||||||
("help.app.rebuild", &[]),
|
("help.app.rebuild", &[]),
|
||||||
@@ -306,7 +308,15 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
|||||||
("parse.usage.select", &[]),
|
("parse.usage.select", &[]),
|
||||||
("parse.usage.show_data", &[]),
|
("parse.usage.show_data", &[]),
|
||||||
("parse.usage.show_table", &[]),
|
("parse.usage.show_table", &[]),
|
||||||
|
("parse.usage.show_tables", &[]),
|
||||||
|
("parse.usage.show_relationships", &[]),
|
||||||
|
("parse.usage.show_indexes", &[]),
|
||||||
|
("parse.usage.show_relationship", &[]),
|
||||||
|
("parse.usage.show_index", &[]),
|
||||||
("parse.usage.update", &[]),
|
("parse.usage.update", &[]),
|
||||||
|
("parse.usage.with", &[]),
|
||||||
|
("parse.expect.select_projection", &[]),
|
||||||
|
("parse.cross_join_no_on", &[]),
|
||||||
// ---- Project lifecycle event notes ----
|
// ---- Project lifecycle event notes ----
|
||||||
("project.export_failed", &["error"]),
|
("project.export_failed", &["error"]),
|
||||||
("project.export_ok", &["path"]),
|
("project.export_ok", &["path"]),
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ help:
|
|||||||
# per line so scroll math stays accurate.
|
# per line so scroll math stays accurate.
|
||||||
intro: "Supported commands:"
|
intro: "Supported commands:"
|
||||||
dsl_section: "DSL data commands (in simple mode):"
|
dsl_section: "DSL data commands (in simple mode):"
|
||||||
|
# H3: footer on the full `help` list, and the not-found note
|
||||||
|
# for `help <topic>`. `{topic}` is the word the user typed.
|
||||||
|
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
|
||||||
|
unknown_topic: "No help for `{topic}`. Type `help` for the full command list, or `help types` for the type reference."
|
||||||
# Per-command help, keyed by `CommandNode.help_id`. Block
|
# Per-command help, keyed by `CommandNode.help_id`. Block
|
||||||
# scalars (`|-`) so the column alignment survives — the
|
# scalars (`|-`) so the column alignment survives — the
|
||||||
# double-quoted form trips a libyml scanner bug on long
|
# double-quoted form trips a libyml scanner bug on long
|
||||||
@@ -248,6 +252,7 @@ help:
|
|||||||
quit — exit the app
|
quit — exit the app
|
||||||
help: |-
|
help: |-
|
||||||
help — show this command list
|
help — show this command list
|
||||||
|
help <command> — detailed help for one command (e.g. `help insert`)
|
||||||
rebuild: |-
|
rebuild: |-
|
||||||
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
|
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
|
||||||
save: |-
|
save: |-
|
||||||
@@ -317,6 +322,11 @@ help:
|
|||||||
show: |-
|
show: |-
|
||||||
show table <T> — show a table's structure
|
show table <T> — show a table's structure
|
||||||
show data <T> — show a table's rows
|
show data <T> — show a table's rows
|
||||||
|
show tables — list all tables
|
||||||
|
show relationships — list all relationships
|
||||||
|
show indexes — list all indexes
|
||||||
|
show relationship <name> — show one relationship's detail
|
||||||
|
show index <name> — show one index's detail
|
||||||
insert: |-
|
insert: |-
|
||||||
insert into <T> [(cols)] [values] (vals) — add a row
|
insert into <T> [(cols)] [values] (vals) — add a row
|
||||||
update: |-
|
update: |-
|
||||||
@@ -491,6 +501,21 @@ parse:
|
|||||||
# command-keyword renderings (each from
|
# command-keyword renderings (each from
|
||||||
# `parse.token.keyword.*`).
|
# `parse.token.keyword.*`).
|
||||||
available_commands: "available commands: {commands}"
|
available_commands: "available commands: {commands}"
|
||||||
|
# ADR-0042 G2: collapse the SELECT projection-start expression
|
||||||
|
# first-set (14 expression-starters plus the `distinct`/`all`
|
||||||
|
# quantifiers) into one learner-sized gloss in the error
|
||||||
|
# message. The detector keys on `distinct` AND `all` being
|
||||||
|
# jointly expectable, which only happens at a projection start —
|
||||||
|
# so the raw set is replaced *in the error line only*;
|
||||||
|
# completion/hints still expand the full first-set.
|
||||||
|
expect:
|
||||||
|
select_projection: "a projection: `*`, a column, or an expression"
|
||||||
|
# ADR-0042 §3: a CROSS JOIN pairs every row and takes no ON
|
||||||
|
# clause. The grammar rejects a following `on`; this message
|
||||||
|
# (rendered in place of the generic structural error when the
|
||||||
|
# most recent join is a CROSS join and the failing token is `on`)
|
||||||
|
# teaches the distinction instead of just "expected end of input".
|
||||||
|
cross_join_no_on: "a CROSS JOIN has no ON clause — it pairs every row; for a join condition use `JOIN … ON`, or filter with `WHERE`"
|
||||||
# Per-command usage templates (ADR-0021 §1). Rendered under a
|
# Per-command usage templates (ADR-0021 §1). Rendered under a
|
||||||
# "usage:" prefix when a parse fails after consuming a
|
# "usage:" prefix when a parse fails after consuming a
|
||||||
# known command-entry keyword. The bracket convention `[...]`
|
# known command-entry keyword. The bracket convention `[...]`
|
||||||
@@ -539,6 +564,11 @@ parse:
|
|||||||
[--force-conversion | --dont-convert]
|
[--force-conversion | --dont-convert]
|
||||||
show_data: "show data <Table>"
|
show_data: "show data <Table>"
|
||||||
show_table: "show table <Table>"
|
show_table: "show table <Table>"
|
||||||
|
show_tables: "show tables"
|
||||||
|
show_relationships: "show relationships"
|
||||||
|
show_indexes: "show indexes"
|
||||||
|
show_relationship: "show relationship <name>"
|
||||||
|
show_index: "show index <name>"
|
||||||
insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])"
|
insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])"
|
||||||
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
|
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
|
||||||
delete: "delete from <Table> (where <col>=<value> | --all-rows)"
|
delete: "delete from <Table> (where <col>=<value> | --all-rows)"
|
||||||
@@ -550,6 +580,10 @@ parse:
|
|||||||
replay: "replay <path> | replay '<path with spaces>'"
|
replay: "replay <path> | replay '<path with spaces>'"
|
||||||
# SQL `SELECT` (advanced mode; ADR-0030 / ADR-0031).
|
# SQL `SELECT` (advanced mode; ADR-0030 / ADR-0031).
|
||||||
select: "select (* | <expr>[ as <alias>][, ...]) from <Table> [where <expr>] [order by <expr>[ asc|desc][, ...]] [limit <n>]"
|
select: "select (* | <expr>[ as <alias>][, ...]) from <Table> [where <expr>] [order by <expr>[ asc|desc][, ...]] [limit <n>]"
|
||||||
|
# SQL `WITH` / CTE (advanced mode; ADR-0032). G4 (ADR-0042):
|
||||||
|
# its own template — `with` previously borrowed `select`'s,
|
||||||
|
# which never showed the CTE shape.
|
||||||
|
with: "with [recursive] <Name> [(<col>[, ...])] as (<query>)[, ...] select ..."
|
||||||
# App-lifecycle commands (per ADR-0003, surfaced through
|
# App-lifecycle commands (per ADR-0003, surfaced through
|
||||||
# the parser so they participate in usage templates +
|
# the parser so they participate in usage templates +
|
||||||
# completion). Templates here describe the surface
|
# completion). Templates here describe the surface
|
||||||
@@ -557,7 +591,7 @@ parse:
|
|||||||
# listing in `help.in_app_body` carries the user-facing
|
# listing in `help.in_app_body` carries the user-facing
|
||||||
# description.
|
# description.
|
||||||
quit: "quit"
|
quit: "quit"
|
||||||
help: "help"
|
help: "help [<command>]"
|
||||||
rebuild: "rebuild"
|
rebuild: "rebuild"
|
||||||
save: "save | save as"
|
save: "save | save as"
|
||||||
new: "new"
|
new: "new"
|
||||||
|
|||||||
+17
-3
@@ -866,7 +866,9 @@ fn ambient_hint_core_in_mode(
|
|||||||
// The form the user has committed to drives the
|
// The form the user has committed to drives the
|
||||||
// usage template — `add index …` shows the
|
// usage template — `add index …` shows the
|
||||||
// `add index` usage, not the first `add` form.
|
// `add index` usage, not the first `add` form.
|
||||||
let usage = crate::dsl::grammar::usage_key_for_input(input)
|
// Mode-aware (ADR-0042 G3): advanced-mode shared
|
||||||
|
// entry words show their SQL form, not the DSL one.
|
||||||
|
let usage = crate::dsl::grammar::usage_key_for_input_in_mode(input, mode)
|
||||||
.map(|key| crate::friendly::translate(key, &[]));
|
.map(|key| crate::friendly::translate(key, &[]));
|
||||||
Some(AmbientHint::Prose(match usage {
|
Some(AmbientHint::Prose(match usage {
|
||||||
Some(u) => crate::t!(
|
Some(u) => crate::t!(
|
||||||
@@ -2048,9 +2050,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambient_hint_at_word_boundary_after_show_returns_data_table() {
|
fn ambient_hint_at_word_boundary_after_show_returns_all_subcommands() {
|
||||||
|
// data / table plus the V5 list-all forms, grammar order.
|
||||||
let cs = cands_hint("show ", 5).expect("candidate hint");
|
let cs = cands_hint("show ", 5).expect("candidate hint");
|
||||||
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
|
assert_eq!(
|
||||||
|
cs,
|
||||||
|
vec![
|
||||||
|
"data".to_string(),
|
||||||
|
"table".to_string(),
|
||||||
|
"tables".to_string(),
|
||||||
|
"relationships".to_string(),
|
||||||
|
"indexes".to_string(),
|
||||||
|
"relationship".to_string(),
|
||||||
|
"index".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+16
-6
@@ -78,6 +78,16 @@ pub fn render_data_table(data: &DataResult) -> Vec<String> {
|
|||||||
/// — `References:` / `Referenced by:` blocks below as plain
|
/// — `References:` / `Referenced by:` blocks below as plain
|
||||||
/// indented text (relationship visualization is its own
|
/// indented text (relationship visualization is its own
|
||||||
/// future ADR per §5 OOS-1).
|
/// 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(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||||
let mut out: Vec<String> = Vec::new();
|
let mut out: Vec<String> = Vec::new();
|
||||||
@@ -112,9 +122,9 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
for r in &desc.outbound_relationships {
|
for r in &desc.outbound_relationships {
|
||||||
out.push(format!(
|
out.push(format!(
|
||||||
" {} → {}.{} ({}, on delete {}, on update {})",
|
" {} → {}.{} ({}, on delete {}, on update {})",
|
||||||
r.local_column,
|
cols_disp(&r.local_columns),
|
||||||
r.other_table,
|
r.other_table,
|
||||||
r.other_column,
|
cols_disp(&r.other_columns),
|
||||||
r.name,
|
r.name,
|
||||||
r.on_delete,
|
r.on_delete,
|
||||||
r.on_update,
|
r.on_update,
|
||||||
@@ -127,8 +137,8 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
|||||||
out.push(format!(
|
out.push(format!(
|
||||||
" {}.{} → {} ({}, on delete {}, on update {})",
|
" {}.{} → {} ({}, on delete {}, on update {})",
|
||||||
r.other_table,
|
r.other_table,
|
||||||
r.other_column,
|
cols_disp(&r.other_columns),
|
||||||
r.local_column,
|
cols_disp(&r.local_columns),
|
||||||
r.name,
|
r.name,
|
||||||
r.on_delete,
|
r.on_delete,
|
||||||
r.on_update,
|
r.on_update,
|
||||||
@@ -769,8 +779,8 @@ mod tests {
|
|||||||
inbound_relationships: vec![RelationshipEnd {
|
inbound_relationships: vec![RelationshipEnd {
|
||||||
name: "cust_orders".to_string(),
|
name: "cust_orders".to_string(),
|
||||||
other_table: "Orders".to_string(),
|
other_table: "Orders".to_string(),
|
||||||
other_column: "cust_id".to_string(),
|
other_columns: vec!["cust_id".to_string()],
|
||||||
local_column: "id".to_string(),
|
local_columns: vec!["id".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -245,9 +245,13 @@ pub struct IndexSchema {
|
|||||||
pub struct RelationshipSchema {
|
pub struct RelationshipSchema {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub parent_table: String,
|
pub parent_table: String,
|
||||||
pub parent_column: String,
|
/// Parent PK column(s); one element for single-column, ordered
|
||||||
|
/// list for a compound-PK FK (ADR-0043). Paired positionally
|
||||||
|
/// with `child_columns`.
|
||||||
|
pub parent_columns: Vec<String>,
|
||||||
pub child_table: String,
|
pub child_table: String,
|
||||||
pub child_column: String,
|
/// Child column(s), positionally paired with `parent_columns`.
|
||||||
|
pub child_columns: Vec<String>,
|
||||||
pub on_delete: ReferentialAction,
|
pub on_delete: ReferentialAction,
|
||||||
pub on_update: ReferentialAction,
|
pub on_update: ReferentialAction,
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-13
@@ -188,20 +188,31 @@ fn write_relationship(out: &mut String, rel: &RelationshipSchema) {
|
|||||||
let _ = writeln!(out, " - name: {}", quote_if_needed(&rel.name));
|
let _ = writeln!(out, " - name: {}", quote_if_needed(&rel.name));
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
" parent: {{ table: {}, column: {} }}",
|
" parent: {{ table: {}, columns: [{}] }}",
|
||||||
quote_if_needed(&rel.parent_table),
|
quote_if_needed(&rel.parent_table),
|
||||||
quote_if_needed(&rel.parent_column),
|
write_col_list(&rel.parent_columns),
|
||||||
);
|
);
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
out,
|
||||||
" child: {{ table: {}, column: {} }}",
|
" child: {{ table: {}, columns: [{}] }}",
|
||||||
quote_if_needed(&rel.child_table),
|
quote_if_needed(&rel.child_table),
|
||||||
quote_if_needed(&rel.child_column),
|
write_col_list(&rel.child_columns),
|
||||||
);
|
);
|
||||||
let _ = writeln!(out, " on_delete: {}", action_keyword(rel.on_delete));
|
let _ = writeln!(out, " on_delete: {}", action_keyword(rel.on_delete));
|
||||||
let _ = writeln!(out, " on_update: {}", action_keyword(rel.on_update));
|
let _ = writeln!(out, " on_update: {}", action_keyword(rel.on_update));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a column list for an inline yaml flow sequence — `a, b`
|
||||||
|
/// (the caller wraps in `[…]`), each element quoted if needed.
|
||||||
|
/// Matches the `primary_key: [...]` / index `columns: [...]` house
|
||||||
|
/// style (ADR-0043 D5). One element for a single-column endpoint.
|
||||||
|
fn write_col_list(cols: &[String]) -> String {
|
||||||
|
cols.iter()
|
||||||
|
.map(|c| quote_if_needed(c))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
const fn action_keyword(action: ReferentialAction) -> &'static str {
|
const fn action_keyword(action: ReferentialAction) -> &'static str {
|
||||||
match action {
|
match action {
|
||||||
ReferentialAction::NoAction => "no_action",
|
ReferentialAction::NoAction => "no_action",
|
||||||
@@ -309,9 +320,9 @@ pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
|||||||
relationships.push(RelationshipSchema {
|
relationships.push(RelationshipSchema {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
parent_table: r.parent.table,
|
parent_table: r.parent.table,
|
||||||
parent_column: r.parent.column,
|
parent_columns: r.parent.columns,
|
||||||
child_table: r.child.table,
|
child_table: r.child.table,
|
||||||
child_column: r.child.column,
|
child_columns: r.child.columns,
|
||||||
on_delete,
|
on_delete,
|
||||||
on_update,
|
on_update,
|
||||||
});
|
});
|
||||||
@@ -502,7 +513,10 @@ struct RawRelationship {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RawEndpoint {
|
struct RawEndpoint {
|
||||||
table: String,
|
table: String,
|
||||||
column: String,
|
/// FK endpoint column list (ADR-0043): `columns: [a, b]`, one
|
||||||
|
/// element for a single-column endpoint — matching the
|
||||||
|
/// `primary_key` / index `columns` house style.
|
||||||
|
columns: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -551,9 +565,9 @@ mod tests {
|
|||||||
relationships: vec![RelationshipSchema {
|
relationships: vec![RelationshipSchema {
|
||||||
name: "Customers_id_to_Orders_CustId".to_string(),
|
name: "Customers_id_to_Orders_CustId".to_string(),
|
||||||
parent_table: "Customers".to_string(),
|
parent_table: "Customers".to_string(),
|
||||||
parent_column: "id".to_string(),
|
parent_columns: vec!["id".to_string()],
|
||||||
child_table: "Orders".to_string(),
|
child_table: "Orders".to_string(),
|
||||||
child_column: "CustId".to_string(),
|
child_columns: vec!["CustId".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
@@ -578,8 +592,8 @@ mod tests {
|
|||||||
assert!(body.contains("{ name: id, type: serial }"));
|
assert!(body.contains("{ name: id, type: serial }"));
|
||||||
assert!(body.contains("{ name: Name, type: text }"));
|
assert!(body.contains("{ name: Name, type: text }"));
|
||||||
assert!(body.contains("- name: Customers_id_to_Orders_CustId"));
|
assert!(body.contains("- name: Customers_id_to_Orders_CustId"));
|
||||||
assert!(body.contains("parent: { table: Customers, column: id }"));
|
assert!(body.contains("parent: { table: Customers, columns: [id] }"));
|
||||||
assert!(body.contains("child: { table: Orders, column: CustId }"));
|
assert!(body.contains("child: { table: Orders, columns: [CustId] }"));
|
||||||
assert!(body.contains("on_delete: cascade"));
|
assert!(body.contains("on_delete: cascade"));
|
||||||
assert!(body.contains("on_update: no_action"));
|
assert!(body.contains("on_update: no_action"));
|
||||||
assert!(body.contains("- name: Orders_CustId_idx"));
|
assert!(body.contains("- name: Orders_CustId_idx"));
|
||||||
@@ -934,8 +948,8 @@ project:
|
|||||||
tables: []
|
tables: []
|
||||||
relationships:
|
relationships:
|
||||||
- name: R
|
- name: R
|
||||||
parent: { table: A, column: id }
|
parent: { table: A, columns: [id] }
|
||||||
child: { table: B, column: aid }
|
child: { table: B, columns: [aid] }
|
||||||
on_delete: blow_up
|
on_delete: blow_up
|
||||||
on_update: no_action
|
on_update: no_action
|
||||||
";
|
";
|
||||||
|
|||||||
+120
-108
@@ -1403,6 +1403,10 @@ fn spawn_dsl_dispatch(
|
|||||||
echo,
|
echo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CommandOutcome::ShowList(lines)) => AppEvent::DslShowListSucceeded {
|
||||||
|
command: command.clone(),
|
||||||
|
lines,
|
||||||
|
},
|
||||||
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
Ok(CommandOutcome::QueryPlan(plan)) => AppEvent::DslExplainSucceeded {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
plan,
|
plan,
|
||||||
@@ -1587,16 +1591,16 @@ struct EchoLookups {
|
|||||||
/// teaching playground).
|
/// teaching playground).
|
||||||
drop_relationship: Option<(String, String)>,
|
drop_relationship: Option<(String, String)>,
|
||||||
/// For `Command::AddRelationship { create_fk: true, .. }` — the
|
/// For `Command::AddRelationship { create_fk: true, .. }` — the
|
||||||
/// type of the child column the `--create-fk` flag will create, *if*
|
/// child columns the `--create-fk` flag will newly create, each with
|
||||||
/// the column did not already exist (`Some(ty)` → newly created →
|
/// its type (ADR-0043: one per child column that did **not** already
|
||||||
/// multi-line echo; `None` → already existed → single-line echo).
|
/// exist, typed to the matching parent PK column's `fk_target_type` —
|
||||||
/// The type is derived from the parent's PK column type via
|
/// ADR-0011: `serial → int`, `shortid → text`, others identity). An
|
||||||
/// `Type::fk_target_type` (ADR-0011: `serial → int`, `shortid →
|
/// **empty** vec means every child column already existed →
|
||||||
/// text`, others identity). The outer `Option` is `None` for
|
/// single-line echo; a non-empty vec → multi-line (one `ADD COLUMN`
|
||||||
/// not-applicable commands (not a `--create-fk` add, or simple mode,
|
/// per element). The outer `Option` is `None` for not-applicable
|
||||||
/// or a pre-execution lookup failed); the inner option encodes the
|
/// commands (not a `--create-fk` add, simple mode, or a
|
||||||
/// existed-vs-created distinction.
|
/// pre-execution lookup failed).
|
||||||
add_rel_create_fk_new_column_type: Option<Option<crate::dsl::types::Type>>,
|
add_rel_create_fk_new_columns: Option<Vec<(String, crate::dsl::types::Type)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve drop-target names and `--create-fk` pre-state **before**
|
/// Resolve drop-target names and `--create-fk` pre-state **before**
|
||||||
@@ -1634,9 +1638,13 @@ async fn collect_echo_lookups(
|
|||||||
} => {
|
} => {
|
||||||
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
|
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
|
||||||
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
|
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
|
||||||
|
// The Endpoints drop selector is single-column
|
||||||
|
// (ADR-0043 keeps DROP by-endpoints single-column;
|
||||||
|
// compound relationships drop by name) — match a
|
||||||
|
// one-column relationship by its sole columns.
|
||||||
r.other_table == *parent_table
|
r.other_table == *parent_table
|
||||||
&& r.other_column == *parent_column
|
&& r.other_columns.as_slice() == std::slice::from_ref(parent_column)
|
||||||
&& r.local_column == *child_column
|
&& r.local_columns.as_slice() == std::slice::from_ref(child_column)
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
out.drop_relationship = Some((rel.name.clone(), child_table.clone()));
|
out.drop_relationship = Some((rel.name.clone(), child_table.clone()));
|
||||||
@@ -1664,41 +1672,37 @@ async fn collect_echo_lookups(
|
|||||||
Command::AddRelationship {
|
Command::AddRelationship {
|
||||||
create_fk: true,
|
create_fk: true,
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
child_table,
|
child_table,
|
||||||
child_column,
|
child_columns,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Two pre-state facts feed the multi-line `--create-fk` echo
|
// Pre-state for the multi-line `--create-fk` echo (ADR-0038
|
||||||
// (ADR-0038 §7 Bucket B, category 2): whether the child
|
// §7 Bucket B, category 2 / ADR-0043): the subset of child
|
||||||
// column already exists (determines single- vs multi-line)
|
// columns that do NOT already exist, each typed to the
|
||||||
// and the parent PK column's user type (determines the
|
// matching parent PK column's `fk_target_type`. Needed
|
||||||
// newly-created child column's type via
|
// *before* execution to know which `ADD COLUMN` lines to
|
||||||
// `Type::fk_target_type`). Both are looked up post-exec from
|
// emit. The parent columns here are the explicit DSL list,
|
||||||
// the description for `add relationship` (no `--create-fk`),
|
// paired positionally with the child list.
|
||||||
// but the `--create-fk` multi-line case needs them *before*
|
let parent_desc = database.describe_table(parent_table.clone(), None).await;
|
||||||
// execution to know whether to emit an `ADD COLUMN` line.
|
let child_desc = database.describe_table(child_table.clone(), None).await;
|
||||||
let parent_pk_type = database
|
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
|
||||||
.describe_table(parent_table.clone(), None)
|
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
|
||||||
.await
|
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
|
||||||
.ok()
|
let already = child_desc.columns.iter().any(|c| c.name == *child_col);
|
||||||
.and_then(|d| {
|
if already {
|
||||||
d.columns
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(parent_ty) = parent_desc
|
||||||
|
.columns
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name == *parent_column)
|
.find(|c| c.name == *parent_col)
|
||||||
.and_then(|c| c.user_type)
|
.and_then(|c| c.user_type)
|
||||||
});
|
{
|
||||||
let child_column_existed = database
|
new_columns.push((child_col.clone(), parent_ty.fk_target_type()));
|
||||||
.describe_table(child_table.clone(), None)
|
}
|
||||||
.await
|
}
|
||||||
.ok()
|
out.add_rel_create_fk_new_columns = Some(new_columns);
|
||||||
.map(|d| d.columns.iter().any(|c| c.name == *child_column));
|
|
||||||
if let (Some(parent_ty), Some(existed)) = (parent_pk_type, child_column_existed) {
|
|
||||||
out.add_rel_create_fk_new_column_type = Some(if existed {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(parent_ty.fk_target_type())
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -1751,9 +1755,9 @@ fn build_schema_echo(
|
|||||||
Command::AddRelationship {
|
Command::AddRelationship {
|
||||||
name,
|
name,
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
child_table,
|
child_table,
|
||||||
child_column,
|
child_columns,
|
||||||
on_delete,
|
on_delete,
|
||||||
on_update,
|
on_update,
|
||||||
create_fk,
|
create_fk,
|
||||||
@@ -1762,57 +1766,55 @@ fn build_schema_echo(
|
|||||||
// relationships (target_table for AddRelationship is the
|
// relationships (target_table for AddRelationship is the
|
||||||
// parent — `database.add_relationship` returns the parent's
|
// parent — `database.add_relationship` returns the parent's
|
||||||
// description per ADR-0013), falling back to the command's
|
// description per ADR-0013), falling back to the command's
|
||||||
// explicit `name` when the description is unavailable.
|
// explicit `name` when the description is unavailable. Match
|
||||||
|
// on the column lists (ADR-0043), the child as `other` and
|
||||||
|
// the parent as `local` from the parent's perspective.
|
||||||
let resolved = description
|
let resolved = description
|
||||||
.and_then(|d| {
|
.and_then(|d| {
|
||||||
d.inbound_relationships.iter().find(|r| {
|
d.inbound_relationships.iter().find(|r| {
|
||||||
r.other_table == *child_table
|
r.other_table == *child_table
|
||||||
&& r.other_column == *child_column
|
&& r.other_columns == *child_columns
|
||||||
&& r.local_column == *parent_column
|
&& r.local_columns == *parent_columns
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map(|r| r.name.clone())
|
.map(|r| r.name.clone())
|
||||||
.or_else(|| name.clone())?;
|
.or_else(|| name.clone())?;
|
||||||
if *create_fk {
|
if *create_fk {
|
||||||
// Multi-line iff the child column was newly created
|
// The pre-execution lookup captured which child columns
|
||||||
// (`--create-fk`'s pre-state, captured pre-execution
|
// `--create-fk` newly creates (ADR-0043). An empty list
|
||||||
// into `add_rel_create_fk_new_column_type`). When the
|
// → every column existed → single-line FK echo; a
|
||||||
// column already existed the echo collapses to the
|
// non-empty list → one `ADD COLUMN` per new column then
|
||||||
// single-line FK form — the SQL `ADD COLUMN` would be
|
// the FK line.
|
||||||
// a no-op-with-error otherwise, and the catalogue is
|
let new_columns = lookups.add_rel_create_fk_new_columns.as_ref()?;
|
||||||
// explicit: "one line if the column already existed".
|
if new_columns.is_empty() {
|
||||||
Some(lookups.add_rel_create_fk_new_column_type?.map_or_else(
|
Some(vec![crate::echo::render_add_relationship(
|
||||||
|| {
|
&resolved,
|
||||||
vec![crate::echo::render_add_relationship(
|
parent_table,
|
||||||
&resolved,
|
parent_columns,
|
||||||
parent_table,
|
child_table,
|
||||||
parent_column,
|
child_columns,
|
||||||
child_table,
|
*on_delete,
|
||||||
child_column,
|
*on_update,
|
||||||
*on_delete,
|
)])
|
||||||
*on_update,
|
} else {
|
||||||
)]
|
Some(crate::echo::render_add_relationship_create_fk(
|
||||||
},
|
&resolved,
|
||||||
|new_ty| {
|
parent_table,
|
||||||
crate::echo::render_add_relationship_create_fk(
|
parent_columns,
|
||||||
&resolved,
|
child_table,
|
||||||
parent_table,
|
child_columns,
|
||||||
parent_column,
|
*on_delete,
|
||||||
child_table,
|
*on_update,
|
||||||
child_column,
|
new_columns,
|
||||||
*on_delete,
|
))
|
||||||
*on_update,
|
}
|
||||||
new_ty,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
Some(vec![crate::echo::render_add_relationship(
|
Some(vec![crate::echo::render_add_relationship(
|
||||||
&resolved,
|
&resolved,
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
child_table,
|
child_table,
|
||||||
child_column,
|
child_columns,
|
||||||
*on_delete,
|
*on_delete,
|
||||||
*on_update,
|
*on_update,
|
||||||
)])
|
)])
|
||||||
@@ -2009,17 +2011,19 @@ async fn enrich_fk_violation(
|
|||||||
};
|
};
|
||||||
facts.table = Some(table.clone());
|
facts.table = Some(table.clone());
|
||||||
for rel in outbound {
|
for rel in outbound {
|
||||||
let value = user_value_for_column_with_schema(
|
// The friendly FK-error facts model is single-column
|
||||||
database,
|
// (ADR-0019); for a compound FK (ADR-0043) we enrich
|
||||||
command,
|
// from the first column pair — the error still surfaces,
|
||||||
table,
|
// richer multi-column enrichment is a later refinement.
|
||||||
&rel.local_column,
|
let Some(local_col) = rel.local_columns.first().cloned() else {
|
||||||
)
|
continue;
|
||||||
.await;
|
};
|
||||||
|
let value =
|
||||||
|
user_value_for_column_with_schema(database, command, table, &local_col).await;
|
||||||
if let Some(v) = value {
|
if let Some(v) = value {
|
||||||
facts.column = Some(rel.local_column);
|
facts.column = Some(local_col);
|
||||||
facts.parent_table = Some(rel.other_table);
|
facts.parent_table = Some(rel.other_table);
|
||||||
facts.parent_column = Some(rel.other_column);
|
facts.parent_column = rel.other_columns.into_iter().next();
|
||||||
facts.value = Some(v.to_string());
|
facts.value = Some(v.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2244,6 +2248,10 @@ enum CommandOutcome {
|
|||||||
/// — skipped" note.
|
/// — skipped" note.
|
||||||
SchemaCreateIndexSkipped(String),
|
SchemaCreateIndexSkipped(String),
|
||||||
Query(DataResult),
|
Query(DataResult),
|
||||||
|
/// A `show <kind>` list (V5) — pre-formatted display lines from
|
||||||
|
/// the worker (table / relationship / index names). Pure
|
||||||
|
/// display, no schema change.
|
||||||
|
ShowList(Vec<String>),
|
||||||
QueryPlan(QueryPlan),
|
QueryPlan(QueryPlan),
|
||||||
Insert(InsertResult),
|
Insert(InsertResult),
|
||||||
Update(UpdateResult),
|
Update(UpdateResult),
|
||||||
@@ -2607,9 +2615,9 @@ async fn execute_command_typed(
|
|||||||
Command::AddRelationship {
|
Command::AddRelationship {
|
||||||
name,
|
name,
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
child_table,
|
child_table,
|
||||||
child_column,
|
child_columns,
|
||||||
on_delete,
|
on_delete,
|
||||||
on_update,
|
on_update,
|
||||||
create_fk,
|
create_fk,
|
||||||
@@ -2617,9 +2625,9 @@ async fn execute_command_typed(
|
|||||||
.add_relationship(
|
.add_relationship(
|
||||||
name,
|
name,
|
||||||
parent_table,
|
parent_table,
|
||||||
parent_column,
|
parent_columns,
|
||||||
child_table,
|
child_table,
|
||||||
child_column,
|
child_columns,
|
||||||
on_delete,
|
on_delete,
|
||||||
on_update,
|
on_update,
|
||||||
create_fk,
|
create_fk,
|
||||||
@@ -2766,6 +2774,10 @@ async fn execute_command_typed(
|
|||||||
.describe_table(name, src)
|
.describe_table(name, src)
|
||||||
.await
|
.await
|
||||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||||
|
Command::ShowList { kind, name } => database
|
||||||
|
.show_list(kind, name)
|
||||||
|
.await
|
||||||
|
.map(CommandOutcome::ShowList),
|
||||||
Command::Insert {
|
Command::Insert {
|
||||||
table,
|
table,
|
||||||
columns,
|
columns,
|
||||||
@@ -3181,9 +3193,9 @@ mod tests {
|
|||||||
.add_relationship(
|
.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -3194,9 +3206,9 @@ mod tests {
|
|||||||
let add_rel_cmd = Command::AddRelationship {
|
let add_rel_cmd = Command::AddRelationship {
|
||||||
name: None,
|
name: None,
|
||||||
parent_table: "Customers".to_string(),
|
parent_table: "Customers".to_string(),
|
||||||
parent_column: "id".to_string(),
|
parent_columns: vec!["id".to_string()],
|
||||||
child_table: "Orders".to_string(),
|
child_table: "Orders".to_string(),
|
||||||
child_column: "CustId".to_string(),
|
child_columns: vec!["CustId".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
create_fk: false,
|
create_fk: false,
|
||||||
@@ -3354,9 +3366,9 @@ mod tests {
|
|||||||
let add_fk_cmd = Command::AddRelationship {
|
let add_fk_cmd = Command::AddRelationship {
|
||||||
name: None,
|
name: None,
|
||||||
parent_table: "Customers".to_string(),
|
parent_table: "Customers".to_string(),
|
||||||
parent_column: "id".to_string(),
|
parent_columns: vec!["id".to_string()],
|
||||||
child_table: "Orders".to_string(),
|
child_table: "Orders".to_string(),
|
||||||
child_column: "CustId".to_string(),
|
child_columns: vec!["CustId".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
create_fk: true,
|
create_fk: true,
|
||||||
@@ -3366,17 +3378,17 @@ mod tests {
|
|||||||
let pre_lookups =
|
let pre_lookups =
|
||||||
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
|
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pre_lookups.add_rel_create_fk_new_column_type,
|
pre_lookups.add_rel_create_fk_new_columns,
|
||||||
Some(Some(Type::Int)),
|
Some(vec![("CustId".to_string(), Type::Int)]),
|
||||||
"pre-exec captures `serial → int` for the newly-created child column",
|
"pre-exec captures `serial → int` for the newly-created child column",
|
||||||
);
|
);
|
||||||
let parent_desc = db
|
let parent_desc = db
|
||||||
.add_relationship(
|
.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
true,
|
true,
|
||||||
@@ -3423,17 +3435,17 @@ mod tests {
|
|||||||
let pre_lookups =
|
let pre_lookups =
|
||||||
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
|
super::collect_echo_lookups(&db, &add_fk_cmd, EffectiveMode::AdvancedPersistent).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pre_lookups.add_rel_create_fk_new_column_type,
|
pre_lookups.add_rel_create_fk_new_columns,
|
||||||
Some(None),
|
Some(vec![]),
|
||||||
"pre-exec records the child column already existed → single-line echo",
|
"pre-exec records the child column already existed → single-line echo",
|
||||||
);
|
);
|
||||||
let parent_desc = db
|
let parent_desc = db
|
||||||
.add_relationship(
|
.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ fn add_relationship_refuses_internal_tables() {
|
|||||||
.block_on(db.add_relationship(
|
.block_on(db.add_relationship(
|
||||||
None,
|
None,
|
||||||
internal.clone(),
|
internal.clone(),
|
||||||
"name".to_string(),
|
vec!["name".to_string()],
|
||||||
"C".to_string(),
|
"C".to_string(),
|
||||||
"x".to_string(),
|
vec!["x".to_string()],
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -161,9 +161,9 @@ fn add_relationship_refuses_internal_tables() {
|
|||||||
.block_on(db.add_relationship(
|
.block_on(db.add_relationship(
|
||||||
None,
|
None,
|
||||||
"P".to_string(),
|
"P".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
internal,
|
internal,
|
||||||
"x".to_string(),
|
vec!["x".to_string()],
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
//! Integration tests for compound-primary-key foreign-key
|
||||||
|
//! references (T3 / ADR-0043) — the DSL `add 1:n relationship`
|
||||||
|
//! surface end to end.
|
||||||
|
//!
|
||||||
|
//! Covers: parse of the parenthesized multi-column endpoint;
|
||||||
|
//! worker round-trip (declare a 2-column FK, FK is enforced,
|
||||||
|
//! per-pair type-mismatch refused, arity mismatch refused);
|
||||||
|
//! persistence round-trip (`columns: [a, b]`); display; and undo.
|
||||||
|
|
||||||
|
use rdbms_playground::db::Database;
|
||||||
|
use rdbms_playground::dsl::{
|
||||||
|
parse_command, ColumnSpec, Command, ReferentialAction, SqlForeignKey, Type, Value,
|
||||||
|
};
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project;
|
||||||
|
|
||||||
|
// ---- parse layer ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parenthesized_compound_endpoint_parses_to_column_lists() {
|
||||||
|
let cmd = parse_command(
|
||||||
|
"add 1:n relationship from Parent.(a, b) to Child.(x, y)",
|
||||||
|
)
|
||||||
|
.expect("parses");
|
||||||
|
match cmd {
|
||||||
|
Command::AddRelationship {
|
||||||
|
parent_table,
|
||||||
|
parent_columns,
|
||||||
|
child_table,
|
||||||
|
child_columns,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(parent_table, "Parent");
|
||||||
|
assert_eq!(parent_columns, vec!["a".to_string(), "b".to_string()]);
|
||||||
|
assert_eq!(child_table, "Child");
|
||||||
|
assert_eq!(child_columns, vec!["x".to_string(), "y".to_string()]);
|
||||||
|
}
|
||||||
|
other => panic!("expected AddRelationship, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_column_endpoint_still_parses_unparenthesized() {
|
||||||
|
let cmd = parse_command("add 1:n relationship from Parent.id to Child.pid")
|
||||||
|
.expect("parses");
|
||||||
|
match cmd {
|
||||||
|
Command::AddRelationship {
|
||||||
|
parent_columns,
|
||||||
|
child_columns,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(parent_columns, vec!["id".to_string()]);
|
||||||
|
assert_eq!(child_columns, vec!["pid".to_string()]);
|
||||||
|
}
|
||||||
|
other => panic!("expected AddRelationship, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SQL surface (advanced mode) --------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_table_level_compound_fk_parses_to_lists() {
|
||||||
|
let cmd = parse_command(
|
||||||
|
"create table City (country int, region_code int, \
|
||||||
|
foreign key (country, region_code) references Region(country, code))",
|
||||||
|
)
|
||||||
|
.expect("parses");
|
||||||
|
match cmd {
|
||||||
|
Command::SqlCreateTable { foreign_keys, .. } => {
|
||||||
|
assert_eq!(foreign_keys.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
foreign_keys[0].child_columns,
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
foreign_keys[0].parent_columns,
|
||||||
|
Some(vec!["country".to_string(), "code".to_string()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected SqlCreateTable, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_bare_compound_reference_parses_with_no_parent_columns() {
|
||||||
|
// `FOREIGN KEY (a, b) REFERENCES P` (no parent cols) — auto-expanded
|
||||||
|
// to the parent's full PK at execution (F-D).
|
||||||
|
let cmd = parse_command(
|
||||||
|
"create table City (country int, region_code int, \
|
||||||
|
foreign key (country, region_code) references Region)",
|
||||||
|
)
|
||||||
|
.expect("parses");
|
||||||
|
match cmd {
|
||||||
|
Command::SqlCreateTable { foreign_keys, .. } => {
|
||||||
|
assert_eq!(
|
||||||
|
foreign_keys[0].child_columns,
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(foreign_keys[0].parent_columns, None);
|
||||||
|
}
|
||||||
|
other => panic!("expected SqlCreateTable, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_create_table_compound_fk_executes_and_enforces() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
// Parent with a compound PK.
|
||||||
|
db.create_table(
|
||||||
|
"Region".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("country", Type::Int),
|
||||||
|
ColumnSpec::new("code", Type::Int),
|
||||||
|
],
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create Region");
|
||||||
|
// Child via the SQL path with a multi-column FK referencing the
|
||||||
|
// full compound PK (resolve_create_table_fks).
|
||||||
|
db.sql_create_table(
|
||||||
|
"City".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("country", Type::Int),
|
||||||
|
ColumnSpec::new("region_code", Type::Int),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![SqlForeignKey {
|
||||||
|
name: None,
|
||||||
|
child_columns: vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
parent_table: "Region".to_string(),
|
||||||
|
parent_columns: Some(vec!["country".to_string(), "code".to_string()]),
|
||||||
|
on_delete: ReferentialAction::NoAction,
|
||||||
|
on_update: ReferentialAction::NoAction,
|
||||||
|
}],
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create City with compound FK");
|
||||||
|
|
||||||
|
db.insert(
|
||||||
|
"Region".to_string(),
|
||||||
|
Some(vec!["country".to_string(), "code".to_string()]),
|
||||||
|
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("insert parent");
|
||||||
|
let bad = db
|
||||||
|
.insert(
|
||||||
|
"City".to_string(),
|
||||||
|
Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||||
|
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(bad.is_err(), "compound FK violation refused by the engine");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- worker round-trip ------------------------------------------
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("create tempdir");
|
||||||
|
let project =
|
||||||
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||||
|
let persistence = Persistence::new(project.path().to_path_buf());
|
||||||
|
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||||
|
.expect("open db with persistence");
|
||||||
|
(project, db, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Region(country int, code int)` compound PK + `City(country int,
|
||||||
|
/// region_code int, name text)` — the child FK columns matching the
|
||||||
|
/// parent PK by type (int → int).
|
||||||
|
async fn seed_compound(db: &Database) {
|
||||||
|
db.create_table(
|
||||||
|
"Region".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("country", Type::Int),
|
||||||
|
ColumnSpec::new("code", Type::Int),
|
||||||
|
],
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create Region");
|
||||||
|
db.create_table(
|
||||||
|
"City".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("country", Type::Int),
|
||||||
|
ColumnSpec::new("region_code", Type::Int),
|
||||||
|
ColumnSpec::new("name", Type::Text),
|
||||||
|
],
|
||||||
|
vec!["country".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create City");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_fk_declares_enforces_and_round_trips() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
seed_compound(&db).await;
|
||||||
|
// Declare the compound FK: City.(country, region_code) →
|
||||||
|
// Region.(country, code).
|
||||||
|
db.add_relationship(
|
||||||
|
Some("city_region".to_string()),
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("add compound relationship");
|
||||||
|
|
||||||
|
// The FK is enforced: a parent row exists for (1, 10); a
|
||||||
|
// child referencing it inserts, one referencing (9, 9) is
|
||||||
|
// refused by the engine.
|
||||||
|
db.insert(
|
||||||
|
"Region".to_string(),
|
||||||
|
Some(vec!["country".to_string(), "code".to_string()]),
|
||||||
|
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("insert parent row");
|
||||||
|
db.insert(
|
||||||
|
"City".to_string(),
|
||||||
|
Some(vec![
|
||||||
|
"country".to_string(),
|
||||||
|
"region_code".to_string(),
|
||||||
|
"name".to_string(),
|
||||||
|
]),
|
||||||
|
vec![Value::Number("1".to_string()), Value::Number("10".to_string()), Value::Text("Metropolis".to_string())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("child row referencing an existing parent key inserts");
|
||||||
|
let violation = db
|
||||||
|
.insert(
|
||||||
|
"City".to_string(),
|
||||||
|
Some(vec![
|
||||||
|
"country".to_string(),
|
||||||
|
"region_code".to_string(),
|
||||||
|
"name".to_string(),
|
||||||
|
]),
|
||||||
|
vec![Value::Number("9".to_string()), Value::Number("9".to_string()), Value::Text("Nowhere".to_string())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
violation.is_err(),
|
||||||
|
"a child row with no matching compound parent key must be refused",
|
||||||
|
);
|
||||||
|
|
||||||
|
// describe shows the compound endpoints symmetrically.
|
||||||
|
let city = db.describe_table("City".to_string(), None).await.unwrap();
|
||||||
|
let outbound = &city.outbound_relationships[0];
|
||||||
|
assert_eq!(
|
||||||
|
outbound.local_columns,
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outbound.other_columns,
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_fk_create_fk_makes_both_child_columns() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
// Region(country, code) compound PK; City has neither FK column.
|
||||||
|
db.create_table(
|
||||||
|
"Region".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("country", Type::Int),
|
||||||
|
ColumnSpec::new("code", Type::Int),
|
||||||
|
],
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create Region");
|
||||||
|
db.create_table(
|
||||||
|
"City".to_string(),
|
||||||
|
vec![ColumnSpec::new("name", Type::Text)],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create City");
|
||||||
|
// --create-fk creates both missing child columns, typed to the
|
||||||
|
// matching parent PK columns' fk_target_type (int → int).
|
||||||
|
db.add_relationship(
|
||||||
|
None,
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["c_country".to_string(), "c_code".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("add compound relationship with --create-fk");
|
||||||
|
let city = db.describe_table("City".to_string(), None).await.unwrap();
|
||||||
|
for col in ["c_country", "c_code"] {
|
||||||
|
assert!(
|
||||||
|
city.columns.iter().any(|c| c.name == col),
|
||||||
|
"--create-fk created `{col}`: {:?}",
|
||||||
|
city.columns.iter().map(|c| &c.name).collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_fk_arity_mismatch_is_refused() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
seed_compound(&db).await;
|
||||||
|
// Two parent columns, one child column → arity mismatch.
|
||||||
|
let err = db
|
||||||
|
.add_relationship(
|
||||||
|
None,
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["country".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(err.is_err(), "mismatched child/parent arity must be refused");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_fk_type_mismatch_per_pair_is_refused() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
db.create_table(
|
||||||
|
"Region".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("country", Type::Int),
|
||||||
|
ColumnSpec::new("code", Type::Int),
|
||||||
|
],
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create Region");
|
||||||
|
// `bad` is `text` — incompatible with the `int` PK column it
|
||||||
|
// would pair with (per-pair type-compat, ADR-0011).
|
||||||
|
db.create_table(
|
||||||
|
"City".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("country", Type::Int),
|
||||||
|
ColumnSpec::new("bad", Type::Text),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create City");
|
||||||
|
let err = db
|
||||||
|
.add_relationship(
|
||||||
|
None,
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["country".to_string(), "bad".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(err.is_err(), "a type-incompatible column pair must be refused");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_fk_survives_rebuild_from_text() {
|
||||||
|
// The riskiest round-trip: comma-encoded metadata + yaml
|
||||||
|
// `columns: [a, b]` → rebuild reconstructs the compound FK DDL.
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let project = project::open_or_create(None, Some(dir.path())).expect("open project");
|
||||||
|
let path = project.path().to_path_buf();
|
||||||
|
let rt = rt();
|
||||||
|
{
|
||||||
|
let db = Database::open_with_persistence(
|
||||||
|
project.db_path(),
|
||||||
|
Persistence::new(path.clone()),
|
||||||
|
)
|
||||||
|
.expect("open db");
|
||||||
|
rt.block_on(async {
|
||||||
|
seed_compound(&db).await;
|
||||||
|
db.add_relationship(
|
||||||
|
Some("city_region".to_string()),
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
Some("add 1:n relationship".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("add compound relationship");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Reopen and rebuild the database purely from the persisted
|
||||||
|
// project.yaml + data/.
|
||||||
|
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||||
|
.expect("reopen db");
|
||||||
|
rt.block_on(async {
|
||||||
|
db.rebuild_from_text(path.clone(), None)
|
||||||
|
.await
|
||||||
|
.expect("rebuild from text");
|
||||||
|
// The compound FK is reconstructed and still enforced.
|
||||||
|
db.insert(
|
||||||
|
"Region".to_string(),
|
||||||
|
Some(vec!["country".to_string(), "code".to_string()]),
|
||||||
|
vec![Value::Number("1".to_string()), Value::Number("10".to_string())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("insert parent after rebuild");
|
||||||
|
let bad = db
|
||||||
|
.insert(
|
||||||
|
"City".to_string(),
|
||||||
|
Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||||
|
vec![Value::Number("9".to_string()), Value::Number("9".to_string())],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
|
||||||
|
// Endpoints survived the round-trip intact.
|
||||||
|
let city = db.describe_table("City".to_string(), None).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
city.outbound_relationships[0].other_columns,
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_fk_undo_removes_the_relationship() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let project = project::open_or_create(None, Some(dir.path())).expect("open project");
|
||||||
|
let db = Database::open_with_persistence_and_undo(
|
||||||
|
project.db_path(),
|
||||||
|
Persistence::new(project.path().to_path_buf()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("open db with undo");
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
seed_compound(&db).await;
|
||||||
|
db.add_relationship(
|
||||||
|
Some("city_region".to_string()),
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string(), "code".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["country".to_string(), "region_code".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
// A user-command source records one undo snapshot.
|
||||||
|
Some("add 1:n relationship".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("add compound relationship");
|
||||||
|
assert_eq!(
|
||||||
|
db.describe_table("City".to_string(), None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.outbound_relationships
|
||||||
|
.len(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// One undo step removes the whole relationship (ADR-0013/0006).
|
||||||
|
db.undo().await.unwrap().expect("undo applied");
|
||||||
|
assert!(
|
||||||
|
db.describe_table("City".to_string(), None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.outbound_relationships
|
||||||
|
.is_empty(),
|
||||||
|
"undo removed the compound relationship in one step",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_fk_partial_pk_reference_is_refused() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(async {
|
||||||
|
seed_compound(&db).await;
|
||||||
|
// Referencing only one column of Region's 2-column PK (F-A:
|
||||||
|
// must reference the full PK).
|
||||||
|
let err = db
|
||||||
|
.add_relationship(
|
||||||
|
None,
|
||||||
|
"Region".to_string(),
|
||||||
|
vec!["country".to_string()],
|
||||||
|
"City".to_string(),
|
||||||
|
vec!["country".to_string()],
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(err.is_err(), "a partial-PK reference must be refused (F-A)");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -420,9 +420,9 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() {
|
|||||||
db.add_relationship(
|
db.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -496,9 +496,9 @@ fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() {
|
|||||||
db.add_relationship(
|
db.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -570,9 +570,9 @@ fn enrich_fk_delete_resolves_child_table() {
|
|||||||
db.add_relationship(
|
db.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Integration tests for `help` and `help <command>` (H3).
|
||||||
|
//!
|
||||||
|
//! Covers:
|
||||||
|
//! - Parse layer: `help` → `Help { topic: None }`; `help insert`
|
||||||
|
//! → `Help { topic: Some("insert") }`.
|
||||||
|
//! - App behaviour: the full `help` ends with the detail hint;
|
||||||
|
//! `help <command>` renders that command's block (and every
|
||||||
|
//! form sharing the entry word); `help types` renders the type
|
||||||
|
//! reference; an unknown topic gets a friendly pointer back.
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
use rdbms_playground::app::App;
|
||||||
|
use rdbms_playground::dsl::{parse_command, AppCommand, Command};
|
||||||
|
use rdbms_playground::event::AppEvent;
|
||||||
|
|
||||||
|
const fn key(code: KeyCode) -> AppEvent {
|
||||||
|
AppEvent::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: crossterm::event::KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(app: &mut App, s: &str) {
|
||||||
|
for c in s.chars() {
|
||||||
|
app.update(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit `input` to a fresh app and collect all output text.
|
||||||
|
fn output_for(input: &str) -> Vec<String> {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, input);
|
||||||
|
app.update(key(KeyCode::Enter));
|
||||||
|
app.output.iter().map(|l| l.text.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Parse layer
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_help_parses_with_no_topic() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("help").expect("parses"),
|
||||||
|
Command::App(AppCommand::Help { topic: None }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_with_topic_captures_the_word() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("help insert").expect("parses"),
|
||||||
|
Command::App(AppCommand::Help {
|
||||||
|
topic: Some("insert".to_string())
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_topic_is_a_single_word_multi_word_is_a_parse_error() {
|
||||||
|
// Entry-word topics cover multi-word commands (`help create`),
|
||||||
|
// so a second word is trailing junk, not a longer topic.
|
||||||
|
assert!(parse_command("help foo bar").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// App behaviour
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_help_lists_commands_and_ends_with_the_detail_hint() {
|
||||||
|
let out = output_for("help");
|
||||||
|
assert!(
|
||||||
|
out.iter().any(|l| l == "Supported commands:"),
|
||||||
|
"intro present: {out:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.iter().any(|l| l.contains("help <command>")),
|
||||||
|
"detail-hint footer present: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_insert_renders_the_insert_block() {
|
||||||
|
let out = output_for("help insert");
|
||||||
|
assert!(
|
||||||
|
out.iter().any(|l| l.contains("insert into")),
|
||||||
|
"insert help shown: {out:?}",
|
||||||
|
);
|
||||||
|
// Focused: it must NOT dump the whole list — the intro header
|
||||||
|
// belongs to the full `help` only.
|
||||||
|
assert!(
|
||||||
|
!out.iter().any(|l| l == "Supported commands:"),
|
||||||
|
"focused help omits the full-list intro: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_create_covers_every_form_sharing_the_entry_word() {
|
||||||
|
// `create` is the entry word for both the DSL `create table`
|
||||||
|
// and the advanced SQL `CREATE TABLE` — `help create` shows
|
||||||
|
// both blocks.
|
||||||
|
let out = output_for("help create");
|
||||||
|
let joined = out.join("\n");
|
||||||
|
assert!(
|
||||||
|
joined.contains("create table"),
|
||||||
|
"DSL create form shown: {out:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
joined.to_lowercase().matches("create").count() >= 2,
|
||||||
|
"more than one create form shown: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_types_renders_the_type_reference() {
|
||||||
|
let out = output_for("help types");
|
||||||
|
let joined = out.join("\n").to_lowercase();
|
||||||
|
// The type reference names the playground types.
|
||||||
|
assert!(
|
||||||
|
joined.contains("serial") || joined.contains("shortid"),
|
||||||
|
"type reference shown: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_unknown_topic_points_back_to_the_full_list() {
|
||||||
|
let out = output_for("help wibble");
|
||||||
|
assert!(
|
||||||
|
out.iter()
|
||||||
|
.any(|l| l.contains("No help for") && l.contains("wibble")),
|
||||||
|
"names the unknown topic: {out:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.iter().any(|l| l.contains("Type `help`")),
|
||||||
|
"points back at the full list: {out:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -192,9 +192,9 @@ fn delete_with_cascade_rewrites_both_csvs() {
|
|||||||
db.add_relationship(
|
db.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -424,9 +424,9 @@ fn project_yaml_carries_relationship_after_add() {
|
|||||||
db.add_relationship(
|
db.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -185,9 +185,9 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
|
|||||||
db.add_relationship(
|
db.add_relationship(
|
||||||
None,
|
None,
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
|
|
||||||
mod case_insensitive_names;
|
mod case_insensitive_names;
|
||||||
mod column_op_guards;
|
mod column_op_guards;
|
||||||
|
mod compound_fk;
|
||||||
mod engine_vocabulary_audit;
|
mod engine_vocabulary_audit;
|
||||||
mod friendly_enrichment;
|
mod friendly_enrichment;
|
||||||
|
mod help_command;
|
||||||
mod iteration2_persistence;
|
mod iteration2_persistence;
|
||||||
mod iteration3_rebuild;
|
mod iteration3_rebuild;
|
||||||
mod iteration4a_rebuild_command;
|
mod iteration4a_rebuild_command;
|
||||||
@@ -30,5 +32,6 @@ mod sql_drop_table;
|
|||||||
mod sql_insert;
|
mod sql_insert;
|
||||||
mod sql_select;
|
mod sql_select;
|
||||||
mod sql_update;
|
mod sql_update;
|
||||||
|
mod show_list;
|
||||||
mod undo_snapshots;
|
mod undo_snapshots;
|
||||||
mod walking_skeleton;
|
mod walking_skeleton;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|||||||
use rdbms_playground::action::Action;
|
use rdbms_playground::action::Action;
|
||||||
use rdbms_playground::app::{App, OutputKind};
|
use rdbms_playground::app::{App, OutputKind};
|
||||||
use rdbms_playground::event::AppEvent;
|
use rdbms_playground::event::AppEvent;
|
||||||
|
use rdbms_playground::mode::Mode;
|
||||||
|
|
||||||
const fn key(code: KeyCode) -> AppEvent {
|
const fn key(code: KeyCode) -> AppEvent {
|
||||||
AppEvent::Key(KeyEvent {
|
AppEvent::Key(KeyEvent {
|
||||||
@@ -59,6 +60,308 @@ fn dump(input: &str, lines: &[String]) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a
|
||||||
|
/// near-correct input plus substrings that MUST appear across its
|
||||||
|
/// rendered error lines — the structural "name the missing
|
||||||
|
/// keyword/clause" message and the per-command usage template.
|
||||||
|
#[test]
|
||||||
|
fn near_miss_matrix_simple_mode() {
|
||||||
|
// (input, required-substrings-across-error-lines)
|
||||||
|
let matrix: &[(&str, &[&str])] = &[
|
||||||
|
// app-lifecycle arg errors. The arg-less commands all reject
|
||||||
|
// trailing junk with "expected end of input" + their usage
|
||||||
|
// (audited 2026-06-05); locked here as regression insurance.
|
||||||
|
("quit now", &["after `quit`, expected end of input", " quit"]),
|
||||||
|
// `help` now takes an optional single-word topic (H3), so
|
||||||
|
// `help foo` parses (topic lookup); only a *multi-word*
|
||||||
|
// topic is the near-miss that rejects trailing junk.
|
||||||
|
("help foo bar", &["after `help foo`, expected end of input", "help [<command>]"]),
|
||||||
|
("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]),
|
||||||
|
("new foo", &["after `new`, expected end of input", " new"]),
|
||||||
|
("load foo", &["after `load`, expected end of input", " load"]),
|
||||||
|
("undo foo", &["after `undo`, expected end of input", " undo"]),
|
||||||
|
("redo foo", &["after `redo`, expected end of input", " redo"]),
|
||||||
|
("export foo bar", &["after `export foo`, expected end of input", "export [<path>]"]),
|
||||||
|
("import a b c", &["after `import a`, expected end of input", "import <zip-path>"]),
|
||||||
|
("save sideways", &["after `save`, expected end of input", "save | save as"]),
|
||||||
|
("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]),
|
||||||
|
("messages louder", &["unknown messages mode 'louder'", "messages short"]),
|
||||||
|
("copy everything", &["unknown copy target 'everything'", "copy all"]),
|
||||||
|
// DDL bare + missing-slot
|
||||||
|
("create", &["after `create`, expected `table`", "create table <Name> with pk"]),
|
||||||
|
("create table", &["after `create table`, expected identifier", "create table <Name> with pk"]),
|
||||||
|
("create table T", &["with pk", "create table <Name> with pk"]),
|
||||||
|
// G1: relationship cardinality reads as the named construct.
|
||||||
|
("add", &["after `add`, expected `column`, `1:n relationship`", "add 1:n relationship"]),
|
||||||
|
("drop table", &["after `drop table`, expected table name", "drop table <Name>"]),
|
||||||
|
("add column", &["after `add column`, expected table name", "add column [to] [table]"]),
|
||||||
|
("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]),
|
||||||
|
("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]),
|
||||||
|
("change", &["after `change`, expected `column`", "change column [in] [table]"]),
|
||||||
|
("change column", &["after `change column`, expected table name", "change column [in] [table]"]),
|
||||||
|
// data bare + missing-clause
|
||||||
|
("insert", &["after `insert`, expected `into`", "insert into <Table>"]),
|
||||||
|
("insert into", &["after `insert into`, expected table name", "insert into <Table>"]),
|
||||||
|
("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into <Table>"]),
|
||||||
|
("update", &["after `update`, expected table name", "update <Table> set"]),
|
||||||
|
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
|
||||||
|
("update T set x=1", &["expected `where` or `--all-rows`", "update <Table> set"]),
|
||||||
|
("delete", &["after `delete`, expected `from`", "delete from <Table>"]),
|
||||||
|
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
|
||||||
|
("delete from T", &["expected `where` or `--all-rows`", "delete from <Table>"]),
|
||||||
|
("replay", &["after `replay`, expected string literal or path", "replay <path>"]),
|
||||||
|
("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]),
|
||||||
|
// advanced-only entry word typed in simple mode → "this is SQL" rail
|
||||||
|
("select * from T", &["`select` is SQL", "mode advanced"]),
|
||||||
|
("alter table T add column c int", &["`alter` is SQL", "mode advanced"]),
|
||||||
|
];
|
||||||
|
for (input, needles) in matrix {
|
||||||
|
let lines = error_lines_for(input);
|
||||||
|
let dump_msg = dump(input, &lines);
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l.starts_with("parse error")),
|
||||||
|
"missing `parse error` line for {input:?}\n{dump_msg}",
|
||||||
|
);
|
||||||
|
let joined = lines.join("\n");
|
||||||
|
for needle in *needles {
|
||||||
|
assert!(
|
||||||
|
joined.contains(needle),
|
||||||
|
"near-miss {input:?} missing expected substring {needle:?}\n{dump_msg}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: advanced-mode error lines for `input`.
|
||||||
|
fn advanced_error_lines_for(input: &str) -> Vec<String> {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.mode = Mode::Advanced;
|
||||||
|
type_str(&mut app, input);
|
||||||
|
let _ = submit(&mut app);
|
||||||
|
app.output
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.kind == OutputKind::Error)
|
||||||
|
.map(|l| l.text.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Committed multi-form near-misses (ADR-0042 §1). A user who has
|
||||||
|
/// already chosen a form (`add index …`, `drop constraint …`,
|
||||||
|
/// `alter table T add …`) must get that form's specific
|
||||||
|
/// missing-keyword/clause message and usage — not the whole family
|
||||||
|
/// generically. Audited 2026-06-05; these render well today and are
|
||||||
|
/// locked here as the residual systematic-pass tail.
|
||||||
|
#[test]
|
||||||
|
fn near_miss_matrix_committed_multiforms() {
|
||||||
|
// (input, advanced?, required-substrings)
|
||||||
|
let matrix: &[(&str, bool, &[&str])] = &[
|
||||||
|
// add / drop multi-forms (simple)
|
||||||
|
("add index", false, &["after `add index`, expected `on` or `as`", "add index [as <Name>] on"]),
|
||||||
|
("add index on T", false, &["after `add index on T`, expected `(`", "add index [as <Name>] on"]),
|
||||||
|
("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]),
|
||||||
|
("add constraint not null", false, &["after `add constraint not null`, expected `to`", "add constraint not null to"]),
|
||||||
|
("add 1:n relationship", false, &["after `add 1:n relationship`, expected `from` or `as`", "add 1:n relationship"]),
|
||||||
|
("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from <Parent>.<col>"]),
|
||||||
|
("drop constraint", false, &["after `drop constraint`, expected `not`, `unique`, `default`, or `check`", "drop constraint (not null"]),
|
||||||
|
("drop constraint not null", false, &["after `drop constraint not null`, expected `from`", "drop constraint (not null"]),
|
||||||
|
("drop index", false, &["after `drop index`, expected `on` or index name", "drop index <Name>", "drop index on <Table>"]),
|
||||||
|
("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on <Table>"]),
|
||||||
|
("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship <Name>"]),
|
||||||
|
("show table", false, &["after `show table`, expected table name", "show table <Table>"]),
|
||||||
|
("show relationship", false, &["after `show relationship`, expected relationship name", "show relationship <name>"]),
|
||||||
|
("show index", false, &["after `show index`, expected index name", "show index <name>"]),
|
||||||
|
("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]),
|
||||||
|
// advanced committed multi-forms
|
||||||
|
("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]),
|
||||||
|
("create unique index", true, &["after `create unique index`, expected `on`, identifier, or `if`", "create [unique] index"]),
|
||||||
|
("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table <Table> add column"]),
|
||||||
|
("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table <Table> drop column"]),
|
||||||
|
];
|
||||||
|
for (input, advanced, needles) in matrix {
|
||||||
|
let lines = if *advanced {
|
||||||
|
advanced_error_lines_for(input)
|
||||||
|
} else {
|
||||||
|
error_lines_for(input)
|
||||||
|
};
|
||||||
|
let dump_msg = dump(input, &lines);
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l.starts_with("parse error")),
|
||||||
|
"missing `parse error` line for {input:?}\n{dump_msg}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!lines.iter().any(|l| l.starts_with("available commands:")),
|
||||||
|
"committed form {input:?} fell back to available-commands\n{dump_msg}",
|
||||||
|
);
|
||||||
|
let joined = lines.join("\n");
|
||||||
|
for needle in *needles {
|
||||||
|
assert!(
|
||||||
|
joined.contains(needle),
|
||||||
|
"committed form {input:?} missing expected substring {needle:?}\n{dump_msg}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_bare_select_collapses_projection_first_set() {
|
||||||
|
// ADR-0042 G2: bare `select` dumped the full 14-item
|
||||||
|
// expression first-set ("`not`, `-`, …, `case`, column name,
|
||||||
|
// `distinct`, or `all`"). Collapse it to a learner-sized
|
||||||
|
// projection gloss in the error MESSAGE only — completion
|
||||||
|
// still expands the raw set (locked by the typing-surface
|
||||||
|
// matrix).
|
||||||
|
let lines = advanced_error_lines_for("select");
|
||||||
|
let joined = lines.join("\n");
|
||||||
|
let dump_msg = dump("select", &lines);
|
||||||
|
assert!(
|
||||||
|
joined.contains("a projection: `*`, a column, or an expression"),
|
||||||
|
"bare `select` should collapse to the projection gloss\n{dump_msg}",
|
||||||
|
);
|
||||||
|
let err_line = lines
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.starts_with("parse error"))
|
||||||
|
.expect("parse error line");
|
||||||
|
assert!(
|
||||||
|
!err_line.contains("`exists`") && !err_line.contains("`case`"),
|
||||||
|
"projection gloss should replace the raw expression first-set\n{dump_msg}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_mode_usage_block_shows_sql_and_dsl_forms() {
|
||||||
|
// ADR-0042 G3: `render_usage_block` was mode-blind — it
|
||||||
|
// resolved shared entry words to the first-registered (Simple)
|
||||||
|
// node, so advanced-mode `create` showed ONLY the DSL `create
|
||||||
|
// table … with pk …` template and none of the SQL forms.
|
||||||
|
// Mode-aware selection shows every form valid in the mode,
|
||||||
|
// SQL-primary first. In advanced mode the DSL forms remain
|
||||||
|
// valid input (verified: `create table Foo with pk` parses and
|
||||||
|
// runs in advanced mode), so they MUST still appear — a usage
|
||||||
|
// hint never hides input that works.
|
||||||
|
let lines = advanced_error_lines_for("create");
|
||||||
|
let joined = lines.join("\n");
|
||||||
|
let dump_msg = dump("create", &lines);
|
||||||
|
assert!(
|
||||||
|
joined.contains("create table [if not exists]"),
|
||||||
|
"advanced `create` should show the SQL create-table usage\n{dump_msg}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
joined.contains("create [unique] index"),
|
||||||
|
"advanced `create` should show the SQL create-index usage\n{dump_msg}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
joined.contains("create table <Name> with pk"),
|
||||||
|
"advanced `create` should ALSO show the DSL `with pk` form (valid in advanced mode)\n{dump_msg}",
|
||||||
|
);
|
||||||
|
// Ordering: the SQL form is listed before the DSL form
|
||||||
|
// (mode-primary first).
|
||||||
|
let sql_at = joined.find("create table [if not exists]").unwrap();
|
||||||
|
let dsl_at = joined.find("create table <Name> with pk").unwrap();
|
||||||
|
assert!(sql_at < dsl_at, "SQL form should precede the DSL form\n{dump_msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_cross_join_with_on_teaches_no_on_clause() {
|
||||||
|
// ADR-0042 §3: a CROSS JOIN has no ON clause. The grammar
|
||||||
|
// rejects a following `on`, but the bare structural error
|
||||||
|
// ("expected end of input") does not teach why. `on` is
|
||||||
|
// unexpected here only because the most recent join is a CROSS
|
||||||
|
// join — every other join flavour *requires* `on` — so the case
|
||||||
|
// is precisely detectable and gets a teaching message.
|
||||||
|
let input = "select * from a cross join b on x = y";
|
||||||
|
let lines = advanced_error_lines_for(input);
|
||||||
|
let joined = lines.join("\n");
|
||||||
|
let dump_msg = dump(input, &lines);
|
||||||
|
assert!(
|
||||||
|
joined.contains("a CROSS JOIN has no ON clause"),
|
||||||
|
"cross join + on should teach that CROSS JOIN takes no ON\n{dump_msg}",
|
||||||
|
);
|
||||||
|
// Misfire guard 1: a plain JOIN missing its ON still asks for `on`.
|
||||||
|
let plain = advanced_error_lines_for("select * from a join b").join("\n");
|
||||||
|
assert!(
|
||||||
|
plain.contains("expected `on`") && !plain.contains("CROSS JOIN"),
|
||||||
|
"a plain join must still ask for ON, not the cross-join message: {plain}",
|
||||||
|
);
|
||||||
|
// Misfire guard 2: a stray `on` with no join present must NOT
|
||||||
|
// claim a CROSS JOIN.
|
||||||
|
let stray = advanced_error_lines_for("select * from a on x = y").join("\n");
|
||||||
|
assert!(
|
||||||
|
!stray.contains("CROSS JOIN has no ON"),
|
||||||
|
"no cross join present — must not fire: {stray}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The advanced-mode near-miss matrix (ADR-0042 §1/§3). Mirrors
|
||||||
|
/// the simple-mode matrix for the SQL surface. Every row must show
|
||||||
|
/// a per-command `usage:` block (never the available-commands
|
||||||
|
/// fallback — that is for unconsumed entry words only).
|
||||||
|
#[test]
|
||||||
|
fn near_miss_matrix_advanced_mode() {
|
||||||
|
let matrix: &[(&str, &[&str])] = &[
|
||||||
|
// SQL select / with (G2, G4)
|
||||||
|
("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]),
|
||||||
|
("select * from", &["after `select * from`, expected table name", "select (* |"]),
|
||||||
|
("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]),
|
||||||
|
// create / drop / alter — SQL forms AND the still-valid DSL
|
||||||
|
// fallback forms, SQL-primary first (G3).
|
||||||
|
("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index", "create table <Name> with pk"]),
|
||||||
|
("create table", &["after `create table`, expected identifier or `if`", "create table [if not exists]"]),
|
||||||
|
("create index", &["after `create index`, expected `on`", "create [unique] index"]),
|
||||||
|
("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]),
|
||||||
|
("alter", &["after `alter`, expected `table`", "alter table <Table> add column"]),
|
||||||
|
("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table <Table>"]),
|
||||||
|
// shared insert/update/delete — must show usage, not the
|
||||||
|
// available-commands fallback (regression guard for the
|
||||||
|
// empty-usage_ids SQL nodes).
|
||||||
|
("insert into T", &["after `insert into T`, expected `values`, `with`, `select`, or `(`", "insert into <Table>"]),
|
||||||
|
("update T", &["after `update T`, expected `set`", "update <Table> set"]),
|
||||||
|
("delete from", &["after `delete from`, expected table name", "delete from <Table>"]),
|
||||||
|
];
|
||||||
|
for (input, needles) in matrix {
|
||||||
|
let lines = advanced_error_lines_for(input);
|
||||||
|
let dump_msg = dump(input, &lines);
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l.starts_with("parse error")),
|
||||||
|
"missing `parse error` line for {input:?}\n{dump_msg}",
|
||||||
|
);
|
||||||
|
// A consumed entry word must yield a usage block, never the
|
||||||
|
// available-commands fallback.
|
||||||
|
assert!(
|
||||||
|
!lines.iter().any(|l| l.starts_with("available commands:")),
|
||||||
|
"advanced {input:?} fell back to available-commands instead of a usage block\n{dump_msg}",
|
||||||
|
);
|
||||||
|
let joined = lines.join("\n");
|
||||||
|
for needle in *needles {
|
||||||
|
assert!(
|
||||||
|
joined.contains(needle),
|
||||||
|
"advanced near-miss {input:?} missing expected substring {needle:?}\n{dump_msg}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_alone_renders_cte_usage_not_select() {
|
||||||
|
// ADR-0042 G4: `with` (advanced-only CTE entry word) borrowed
|
||||||
|
// the `select` usage template, which never mentions the CTE
|
||||||
|
// shape. It now carries its own `parse.usage.with`.
|
||||||
|
let mut app = App::new();
|
||||||
|
app.mode = Mode::Advanced;
|
||||||
|
type_str(&mut app, "with");
|
||||||
|
let _ = submit(&mut app);
|
||||||
|
let lines: Vec<String> = app
|
||||||
|
.output
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.kind == OutputKind::Error)
|
||||||
|
.map(|l| l.text.clone())
|
||||||
|
.collect();
|
||||||
|
let dump_msg = dump("with", &lines);
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l.trim_start().starts_with("with ") && l.contains("as (")),
|
||||||
|
"missing CTE-specific `with … as (…)` usage template\n{dump_msg}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_alone_renders_create_table_usage() {
|
fn create_alone_renders_create_table_usage() {
|
||||||
let lines = error_lines_for("create");
|
let lines = error_lines_for("create");
|
||||||
@@ -81,15 +384,18 @@ fn create_alone_renders_create_table_usage() {
|
|||||||
fn add_alone_renders_both_add_family_usages() {
|
fn add_alone_renders_both_add_family_usages() {
|
||||||
let lines = error_lines_for("add");
|
let lines = error_lines_for("add");
|
||||||
let dump_msg = dump("add", &lines);
|
let dump_msg = dump("add", &lines);
|
||||||
// Aggregation across `choice` (ADR-0020): the structural
|
// Aggregation across the top-level choice: the structural
|
||||||
// error line lists both add-family entries.
|
// error line lists every add-family branch. ADR-0042 G1: the
|
||||||
|
// relationship branch renders as the friendly `1:n
|
||||||
|
// relationship` rather than the cryptic bare `1` cardinality
|
||||||
|
// literal.
|
||||||
assert!(
|
assert!(
|
||||||
lines.iter().any(|l| {
|
lines.iter().any(|l| {
|
||||||
l.starts_with("parse error")
|
l.starts_with("parse error")
|
||||||
&& l.contains("`1`")
|
&& l.contains("`1:n relationship`")
|
||||||
&& l.contains("`column`")
|
&& l.contains("`column`")
|
||||||
}),
|
}),
|
||||||
"expected aggregated `1` or `column` in structural error\n{dump_msg}",
|
"expected aggregated `1:n relationship` and `column` in structural error\n{dump_msg}",
|
||||||
);
|
);
|
||||||
// Usage block (ADR-0021): both add-* templates surface.
|
// Usage block (ADR-0021): both add-* templates surface.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -229,3 +535,7 @@ fn caret_aligns_under_offending_token() {
|
|||||||
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
|
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
//! Integration tests for the `show <kind>` list commands (V5):
|
||||||
|
//! `show tables`, `show relationships`, `show indexes`.
|
||||||
|
//!
|
||||||
|
//! Covers:
|
||||||
|
//! - Parse layer: each form parses to `Command::ShowList { kind }`
|
||||||
|
//! in both simple and advanced mode (the forms are
|
||||||
|
//! `CommandCategory::Simple`, available in both).
|
||||||
|
//! - Worker round-trip: `Database::show_list` returns the correct
|
||||||
|
//! pre-formatted lines after real DDL (tables, a relationship,
|
||||||
|
//! an index), and the empty-collection wording.
|
||||||
|
//! - App end-to-end: submitting `show tables` reaches the output
|
||||||
|
//! panel as system lines and marks the echo complete.
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
use rdbms_playground::action::Action;
|
||||||
|
use rdbms_playground::app::App;
|
||||||
|
use rdbms_playground::db::Database;
|
||||||
|
use rdbms_playground::dsl::{
|
||||||
|
parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type,
|
||||||
|
};
|
||||||
|
use rdbms_playground::event::AppEvent;
|
||||||
|
use rdbms_playground::mode::Mode;
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project;
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Parse layer
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_tables_parses_as_show_list_tables() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("show tables").expect("parses"),
|
||||||
|
Command::ShowList {
|
||||||
|
kind: ShowListKind::Tables,
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_relationships_parses_as_show_list_relationships() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("show relationships").expect("parses"),
|
||||||
|
Command::ShowList {
|
||||||
|
kind: ShowListKind::Relationships,
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_indexes_parses_as_show_list_indexes() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("show indexes").expect("parses"),
|
||||||
|
Command::ShowList {
|
||||||
|
kind: ShowListKind::Indexes,
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_relationship_singular_parses_with_name() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("show relationship orders_customer").expect("parses"),
|
||||||
|
Command::ShowList {
|
||||||
|
kind: ShowListKind::Relationships,
|
||||||
|
name: Some("orders_customer".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_index_singular_parses_with_name() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("show index idx_orders_customer").expect("parses"),
|
||||||
|
Command::ShowList {
|
||||||
|
kind: ShowListKind::Indexes,
|
||||||
|
name: Some("idx_orders_customer".to_string()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_table_singular_still_parses_as_show_table() {
|
||||||
|
// The new plural keyword must not shadow the singular
|
||||||
|
// `show table <name>` form — `table` ≠ `tables`.
|
||||||
|
assert_eq!(
|
||||||
|
parse_command("show table Customers").expect("parses"),
|
||||||
|
Command::ShowTable {
|
||||||
|
name: "Customers".to_string()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Worker round-trip — real execution
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
||||||
|
let dir = tempfile::tempdir().expect("create tempdir");
|
||||||
|
let project =
|
||||||
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
||||||
|
let persistence = Persistence::new(project.path().to_path_buf());
|
||||||
|
let db = Database::open_with_persistence(project.db_path(), persistence)
|
||||||
|
.expect("open db with persistence");
|
||||||
|
(project, db, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create two related tables plus an index, so each list kind has
|
||||||
|
/// content. Returns once the worker has applied everything.
|
||||||
|
async fn seed_schema(db: &Database) {
|
||||||
|
db.create_table(
|
||||||
|
"Customers".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("id", Type::Serial),
|
||||||
|
ColumnSpec::new("Name", Type::Text),
|
||||||
|
],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create Customers");
|
||||||
|
db.create_table(
|
||||||
|
"Orders".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec::new("id", Type::Serial),
|
||||||
|
ColumnSpec::new("customer_id", Type::Int),
|
||||||
|
],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("create Orders");
|
||||||
|
db.add_relationship(
|
||||||
|
Some("orders_customer".to_string()),
|
||||||
|
"Customers".to_string(),
|
||||||
|
vec!["id".to_string()],
|
||||||
|
"Orders".to_string(),
|
||||||
|
vec!["customer_id".to_string()],
|
||||||
|
ReferentialAction::Cascade,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("add relationship");
|
||||||
|
db.add_index(
|
||||||
|
Some("idx_orders_customer".to_string()),
|
||||||
|
"Orders".to_string(),
|
||||||
|
vec!["customer_id".to_string()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("add index");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_tables_lists_all_user_tables_with_count_header() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(seed_schema(&db));
|
||||||
|
let lines = rt
|
||||||
|
.block_on(db.show_list(ShowListKind::Tables, None))
|
||||||
|
.expect("show tables");
|
||||||
|
assert_eq!(lines[0], "Tables (2):", "count header");
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l == " Customers"),
|
||||||
|
"Customers listed: {lines:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l == " Orders"),
|
||||||
|
"Orders listed: {lines:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_relationships_lists_name_endpoints_and_nondefault_action() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(seed_schema(&db));
|
||||||
|
let lines = rt
|
||||||
|
.block_on(db.show_list(ShowListKind::Relationships, None))
|
||||||
|
.expect("show relationships");
|
||||||
|
assert_eq!(lines[0], "Relationships (1):");
|
||||||
|
// Name, both endpoints, and the non-default ON DELETE CASCADE
|
||||||
|
// (ON UPDATE NO ACTION is the default and is omitted).
|
||||||
|
assert_eq!(
|
||||||
|
lines[1],
|
||||||
|
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
|
||||||
|
"relationship summary line: {lines:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_indexes_lists_qualified_name_and_columns() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(seed_schema(&db));
|
||||||
|
let lines = rt
|
||||||
|
.block_on(db.show_list(ShowListKind::Indexes, None))
|
||||||
|
.expect("show indexes");
|
||||||
|
assert_eq!(lines[0], "Indexes (1):");
|
||||||
|
assert_eq!(
|
||||||
|
lines[1], " Orders.idx_orders_customer (customer_id)",
|
||||||
|
"index summary line: {lines:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_lists_report_empty_collections_with_friendly_lines() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
// No schema seeded — every kind is empty.
|
||||||
|
assert_eq!(
|
||||||
|
rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(),
|
||||||
|
vec!["No tables in this project yet.".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rt.block_on(db.show_list(ShowListKind::Relationships, None))
|
||||||
|
.unwrap(),
|
||||||
|
vec!["No relationships in this project yet.".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(),
|
||||||
|
vec!["No indexes in this project yet.".to_string()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// V5a — singular per-item detail
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_one_relationship_renders_detail_block() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(seed_schema(&db));
|
||||||
|
let lines = rt
|
||||||
|
.block_on(db.show_list(ShowListKind::Relationships, Some("orders_customer".to_string())))
|
||||||
|
.expect("show relationship");
|
||||||
|
assert_eq!(lines[0], "Relationship `orders_customer`:");
|
||||||
|
assert_eq!(lines[1], " Customers.id → Orders.customer_id");
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l == " on delete cascade"),
|
||||||
|
"on-delete shown: {lines:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_one_index_renders_detail_block() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(seed_schema(&db));
|
||||||
|
let lines = rt
|
||||||
|
.block_on(db.show_list(ShowListKind::Indexes, Some("idx_orders_customer".to_string())))
|
||||||
|
.expect("show index");
|
||||||
|
assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:");
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l == " columns: customer_id"),
|
||||||
|
"columns shown: {lines:?}",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
lines.iter().any(|l| l == " unique: no"),
|
||||||
|
"uniqueness shown: {lines:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_one_unknown_name_reports_friendly_not_found() {
|
||||||
|
let (_p, db, _dir) = open_project_db();
|
||||||
|
let rt = rt();
|
||||||
|
rt.block_on(seed_schema(&db));
|
||||||
|
assert_eq!(
|
||||||
|
rt.block_on(db.show_list(ShowListKind::Relationships, Some("nope".to_string())))
|
||||||
|
.unwrap(),
|
||||||
|
vec!["No relationship named `nope`.".to_string()],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rt.block_on(db.show_list(ShowListKind::Indexes, Some("nope".to_string())))
|
||||||
|
.unwrap(),
|
||||||
|
vec!["No index named `nope`.".to_string()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// App end-to-end
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
const fn key(code: KeyCode) -> AppEvent {
|
||||||
|
AppEvent::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: crossterm::event::KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(app: &mut App, s: &str) {
|
||||||
|
for c in s.chars() {
|
||||||
|
app.update(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_show_tables_dispatches_show_list_command() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.mode = Mode::Simple;
|
||||||
|
type_str(&mut app, "show tables");
|
||||||
|
let actions = app.update(key(KeyCode::Enter));
|
||||||
|
let dispatched = actions.iter().any(|a| {
|
||||||
|
matches!(
|
||||||
|
a,
|
||||||
|
Action::ExecuteDsl {
|
||||||
|
command: Command::ShowList {
|
||||||
|
kind: ShowListKind::Tables,
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_renders_show_list_lines_as_system_output() {
|
||||||
|
// Feed the success event directly so the test stays
|
||||||
|
// self-contained (the worker round-trip is covered above).
|
||||||
|
let mut app = App::new();
|
||||||
|
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
||||||
|
"show tables",
|
||||||
|
Mode::Simple,
|
||||||
|
));
|
||||||
|
app.update(AppEvent::DslShowListSucceeded {
|
||||||
|
command: Command::ShowList {
|
||||||
|
kind: ShowListKind::Tables,
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
lines: vec!["Tables (1):".to_string(), " Customers".to_string()],
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
app.output.iter().any(|l| l.text == "Tables (1):"),
|
||||||
|
"header line rendered",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.output.iter().any(|l| l.text == " Customers"),
|
||||||
|
"item line rendered",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -834,9 +834,9 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() {
|
|||||||
fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> SqlForeignKey {
|
fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> SqlForeignKey {
|
||||||
SqlForeignKey {
|
SqlForeignKey {
|
||||||
name: None,
|
name: None,
|
||||||
child_column: child_column.to_string(),
|
child_columns: vec![child_column.to_string()],
|
||||||
parent_table: parent_table.to_string(),
|
parent_table: parent_table.to_string(),
|
||||||
parent_column: parent_column.map(str::to_string),
|
parent_columns: parent_column.map(|c| vec![c.to_string()]),
|
||||||
on_delete: ReferentialAction::NoAction,
|
on_delete: ReferentialAction::NoAction,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}
|
}
|
||||||
@@ -929,7 +929,7 @@ fn foreign_key_creates_named_relationship_visible_in_describe() {
|
|||||||
let rel = &child.outbound_relationships[0];
|
let rel = &child.outbound_relationships[0];
|
||||||
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
|
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
|
||||||
assert_eq!(rel.other_table, "parent");
|
assert_eq!(rel.other_table, "parent");
|
||||||
assert_eq!(rel.local_column, "pid");
|
assert_eq!(rel.local_columns, vec!["pid".to_string()]);
|
||||||
|
|
||||||
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
||||||
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
|
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
|
||||||
@@ -974,7 +974,7 @@ fn bare_references_resolves_to_parent_single_column_pk() {
|
|||||||
))
|
))
|
||||||
.expect("create child with bare REFERENCES");
|
.expect("create child with bare REFERENCES");
|
||||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
||||||
assert_eq!(child.outbound_relationships[0].other_column, "id", "resolved to parent PK");
|
assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1341,7 +1341,7 @@ fn bare_self_reference_resolves_to_own_pk() {
|
|||||||
))
|
))
|
||||||
.expect("create self-referential emp with a bare reference");
|
.expect("create self-referential emp with a bare reference");
|
||||||
let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe");
|
let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe");
|
||||||
assert_eq!(emp.outbound_relationships[0].other_column, "id", "bare self-ref resolved to own PK");
|
assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK");
|
||||||
// Enforced: a non-existent manager is rejected.
|
// Enforced: a non-existent manager is rejected.
|
||||||
r.block_on(db.insert(
|
r.block_on(db.insert(
|
||||||
"emp".to_string(),
|
"emp".to_string(),
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
|
|||||||
rt.block_on(db.add_relationship(
|
rt.block_on(db.add_relationship(
|
||||||
Some("places".to_string()),
|
Some("places".to_string()),
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -289,9 +289,9 @@ fn cascade_to_two_children_reports_both() {
|
|||||||
rt.block_on(db.add_relationship(
|
rt.block_on(db.add_relationship(
|
||||||
Some(name.to_string()),
|
Some(name.to_string()),
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
child.to_string(),
|
child.to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -358,9 +358,9 @@ fn delete_violating_fk_fails_and_persists_nothing() {
|
|||||||
rt.block_on(db.add_relationship(
|
rt.block_on(db.add_relationship(
|
||||||
Some("places".to_string()),
|
Some("places".to_string()),
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::NoAction, // on delete: reject if referenced
|
ReferentialAction::NoAction, // on delete: reject if referenced
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
@@ -395,9 +395,9 @@ fn self_referential_cascade_counts_only_cascaded_rows() {
|
|||||||
rt.block_on(db.add_relationship(
|
rt.block_on(db.add_relationship(
|
||||||
Some("parent_of".to_string()),
|
Some("parent_of".to_string()),
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
"ParentId".to_string(),
|
vec!["ParentId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -318,9 +318,9 @@ fn cascade_fixture(db: &Database, rt: &tokio::runtime::Runtime) {
|
|||||||
rt.block_on(db.add_relationship(
|
rt.block_on(db.add_relationship(
|
||||||
Some("places".to_string()),
|
Some("places".to_string()),
|
||||||
"Customers".to_string(),
|
"Customers".to_string(),
|
||||||
"id".to_string(),
|
vec!["id".to_string()],
|
||||||
"Orders".to_string(),
|
"Orders".to_string(),
|
||||||
"CustId".to_string(),
|
vec!["CustId".to_string()],
|
||||||
ReferentialAction::Cascade,
|
ReferentialAction::Cascade,
|
||||||
ReferentialAction::NoAction,
|
ReferentialAction::NoAction,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ fn dropping_a_referenced_parent_is_refused() {
|
|||||||
vec![],
|
vec![],
|
||||||
vec![SqlForeignKey {
|
vec![SqlForeignKey {
|
||||||
name: None,
|
name: None,
|
||||||
child_column: "pid".to_string(),
|
child_columns: vec!["pid".to_string()],
|
||||||
parent_table: "parent".to_string(),
|
parent_table: "parent".to_string(),
|
||||||
parent_column: Some("id".to_string()),
|
parent_columns: Some(vec!["id".to_string()]),
|
||||||
on_delete: rdbms_playground::dsl::ReferentialAction::NoAction,
|
on_delete: rdbms_playground::dsl::ReferentialAction::NoAction,
|
||||||
on_update: rdbms_playground::dsl::ReferentialAction::NoAction,
|
on_update: rdbms_playground::dsl::ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -420,9 +420,9 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
|||||||
&Command::AddRelationship {
|
&Command::AddRelationship {
|
||||||
name: None,
|
name: None,
|
||||||
parent_table: "Customers".to_string(),
|
parent_table: "Customers".to_string(),
|
||||||
parent_column: "Id".to_string(),
|
parent_columns: vec!["Id".to_string()],
|
||||||
child_table: "Orders".to_string(),
|
child_table: "Orders".to_string(),
|
||||||
child_column: "CustId".to_string(),
|
child_columns: vec!["CustId".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
create_fk: false,
|
create_fk: false,
|
||||||
@@ -449,8 +449,8 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
|||||||
inbound_relationships: vec![RelationshipEnd {
|
inbound_relationships: vec![RelationshipEnd {
|
||||||
name: "Customers_Id_to_Orders_CustId".to_string(),
|
name: "Customers_Id_to_Orders_CustId".to_string(),
|
||||||
other_table: "Orders".to_string(),
|
other_table: "Orders".to_string(),
|
||||||
other_column: "CustId".to_string(),
|
other_columns: vec!["CustId".to_string()],
|
||||||
local_column: "Id".to_string(),
|
local_columns: vec!["Id".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
@@ -462,9 +462,9 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
|||||||
command: Command::AddRelationship {
|
command: Command::AddRelationship {
|
||||||
name: None,
|
name: None,
|
||||||
parent_table: "Customers".to_string(),
|
parent_table: "Customers".to_string(),
|
||||||
parent_column: "Id".to_string(),
|
parent_columns: vec!["Id".to_string()],
|
||||||
child_table: "Orders".to_string(),
|
child_table: "Orders".to_string(),
|
||||||
child_column: "CustId".to_string(),
|
child_columns: vec!["CustId".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
create_fk: false,
|
create_fk: false,
|
||||||
@@ -504,8 +504,8 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
|
|||||||
inbound_relationships: vec![RelationshipEnd {
|
inbound_relationships: vec![RelationshipEnd {
|
||||||
name: "Customers_Id_to_Orders_CustId".to_string(),
|
name: "Customers_Id_to_Orders_CustId".to_string(),
|
||||||
other_table: "Orders".to_string(),
|
other_table: "Orders".to_string(),
|
||||||
other_column: "CustId".to_string(),
|
other_columns: vec!["CustId".to_string()],
|
||||||
local_column: "Id".to_string(),
|
local_columns: vec!["Id".to_string()],
|
||||||
on_delete: ReferentialAction::Cascade,
|
on_delete: ReferentialAction::Cascade,
|
||||||
on_update: ReferentialAction::NoAction,
|
on_update: ReferentialAction::NoAction,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
AddConstraint { .. } => "AddConstraint".into(),
|
AddConstraint { .. } => "AddConstraint".into(),
|
||||||
DropConstraint { .. } => "DropConstraint".into(),
|
DropConstraint { .. } => "DropConstraint".into(),
|
||||||
ShowTable { .. } => "ShowTable".into(),
|
ShowTable { .. } => "ShowTable".into(),
|
||||||
|
ShowList { kind, name } => format!("ShowList({kind:?}, {})", name.is_some()),
|
||||||
Insert { .. } => "Insert".into(),
|
Insert { .. } => "Insert".into(),
|
||||||
Update { .. } => "Update".into(),
|
Update { .. } => "Update".into(),
|
||||||
Delete { .. } => "Delete".into(),
|
Delete { .. } => "Delete".into(),
|
||||||
@@ -245,7 +246,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
|||||||
SqlDelete { .. } => "SqlDelete".into(),
|
SqlDelete { .. } => "SqlDelete".into(),
|
||||||
App(app) => match app {
|
App(app) => match app {
|
||||||
AppCommand::Quit => "App(Quit)".into(),
|
AppCommand::Quit => "App(Quit)".into(),
|
||||||
AppCommand::Help => "App(Help)".into(),
|
AppCommand::Help { .. } => "App(Help)".into(),
|
||||||
AppCommand::Rebuild => "App(Rebuild)".into(),
|
AppCommand::Rebuild => "App(Rebuild)".into(),
|
||||||
AppCommand::Save => "App(Save)".into(),
|
AppCommand::Save => "App(Save)".into(),
|
||||||
AppCommand::SaveAs => "App(SaveAs)".into(),
|
AppCommand::SaveAs => "App(SaveAs)".into(),
|
||||||
|
|||||||
+11
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/typing_surface/add_relationship.rs
|
source: tests/typing_surface/add_relationship.rs
|
||||||
|
assertion_line: 64
|
||||||
description: "input=\"add 1:n relationship from Customers.id to Orders.\" cursor=49"
|
description: "input=\"add 1:n relationship from Customers.id to Orders.\" cursor=49"
|
||||||
expression: "& a"
|
expression: "& a"
|
||||||
---
|
---
|
||||||
@@ -25,6 +26,11 @@ Assessment {
|
|||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "(",
|
||||||
|
kind: Punct,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selected: None,
|
selected: None,
|
||||||
},
|
},
|
||||||
@@ -52,6 +58,11 @@ Assessment {
|
|||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "(",
|
||||||
|
kind: Punct,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
+11
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: tests/typing_surface/add_relationship.rs
|
source: tests/typing_surface/add_relationship.rs
|
||||||
|
assertion_line: 51
|
||||||
description: "input=\"add 1:n relationship from Customers.\" cursor=36"
|
description: "input=\"add 1:n relationship from Customers.\" cursor=36"
|
||||||
expression: "& a"
|
expression: "& a"
|
||||||
---
|
---
|
||||||
@@ -20,6 +21,11 @@ Assessment {
|
|||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "(",
|
||||||
|
kind: Punct,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selected: None,
|
selected: None,
|
||||||
},
|
},
|
||||||
@@ -42,6 +48,11 @@ Assessment {
|
|||||||
kind: Identifier,
|
kind: Identifier,
|
||||||
mode: Both,
|
mode: Both,
|
||||||
},
|
},
|
||||||
|
Candidate {
|
||||||
|
text: "(",
|
||||||
|
kind: Punct,
|
||||||
|
mode: Both,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@ This is the **living** home for documentation authoring conventions for the
|
|||||||
RDBMS Playground website. It grows as we write.
|
RDBMS Playground website. It grows as we write.
|
||||||
|
|
||||||
- **Binding rules** come from
|
- **Binding rules** come from
|
||||||
[ADR-0042 §7](../docs/adr/0042-public-website-and-documentation-site.md);
|
[ADR-0044 §7](../docs/adr/0044-public-website-and-documentation-site.md);
|
||||||
this guide must not contradict them. If a convention is significant,
|
this guide must not contradict them. If a convention is significant,
|
||||||
durable, or contested, it is decided in an **ADR** (new or amended), and
|
durable, or contested, it is decided in an **ADR** (new or amended), and
|
||||||
this guide references it. Finer, settled conventions live here directly.
|
this guide references it. Finer, settled conventions live here directly.
|
||||||
@@ -17,7 +17,7 @@ Status tags used below: **[DECIDED]** (binding or settled) ·
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Terminology & wording [DECIDED — ADR-0042 §7]
|
## Terminology & wording [DECIDED — ADR-0044 §7]
|
||||||
|
|
||||||
- **No "DSL".** It is internal jargon. Use **simple mode** (the playground's
|
- **No "DSL".** It is internal jargon. Use **simple mode** (the playground's
|
||||||
keyword command language) and **advanced mode** (SQL).
|
keyword command language) and **advanced mode** (SQL).
|
||||||
@@ -60,7 +60,7 @@ Ground every reference page in source — `parse.usage.*` and `help.*` in
|
|||||||
`src/friendly/strings/en-US.yaml`, `src/dsl/command.rs`, `src/dsl/types.rs`
|
`src/friendly/strings/en-US.yaml`, `src/dsl/command.rs`, `src/dsl/types.rs`
|
||||||
— never paraphrase grammar from memory.
|
— never paraphrase grammar from memory.
|
||||||
|
|
||||||
## "Planned / not yet available" callouts [DECIDED — ADR-0042 §7]
|
## "Planned / not yet available" callouts [DECIDED — ADR-0044 §7]
|
||||||
|
|
||||||
Any capability that is not yet fully implemented is **omitted** or carries a
|
Any capability that is not yet fully implemented is **omitted** or carries a
|
||||||
clear callout — never presented as shipped. Standard form: a Starlight aside
|
clear callout — never presented as shipped. Standard form: a Starlight aside
|
||||||
@@ -104,7 +104,7 @@ a small standalone example, not by complicating this schema.
|
|||||||
- Pair a hero/landing cast with a text transcript or the equivalent docs
|
- Pair a hero/landing cast with a text transcript or the equivalent docs
|
||||||
snippet (accessibility + SEO).
|
snippet (accessibility + SEO).
|
||||||
- Recorded via a scripted-input driver for paced, re-recordable sessions
|
- Recorded via a scripted-input driver for paced, re-recordable sessions
|
||||||
(ADR-0042 §2; recipe in `README.md`). **[OPEN]**: cast file naming,
|
(ADR-0044 §2; recipe in `README.md`). **[OPEN]**: cast file naming,
|
||||||
fixed terminal size, light/dark theme handling.
|
fixed terminal size, light/dark theme handling.
|
||||||
|
|
||||||
## Formatting [DECIDED, refine]
|
## Formatting [DECIDED, refine]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
// public home. Omitted for now rather than linking the wrong repo.
|
// public home. Omitted for now rather than linking the wrong repo.
|
||||||
// social: [{ icon: 'github', label: 'GitHub', href: '…' }],
|
// social: [{ icon: 'github', label: 'GitHub', href: '…' }],
|
||||||
customCss: ['./src/styles/global.css'],
|
customCss: ['./src/styles/global.css'],
|
||||||
// Pragmatic structure (ADR-0042 §7 / website/STYLE.md): Getting
|
// Pragmatic structure (ADR-0044 §7 / website/STYLE.md): Getting
|
||||||
// started, Guides, Reference, Concepts. Autogenerated per directory;
|
// started, Guides, Reference, Concepts. Autogenerated per directory;
|
||||||
// in-section order is controlled by each page's `sidebar.order`
|
// in-section order is controlled by each page's `sidebar.order`
|
||||||
// frontmatter.
|
// frontmatter.
|
||||||
|
|||||||
Reference in New Issue
Block a user