Merge branch 'main' into website (m:n, logging, UI nav, demo overlays, vi-nav)
Brings a large batch of app work onto the website branch so the docs (and casts) can reflect it: - #24 vi-style j/k/g/G navigation in the load picker (ADR-0047 era) — unblocks a scriptable projects cast (autocast can send j/k; not arrows) - #22 demonstration overlay layer (ADR-0047): `--demo` mode, keystroke badges, and step-caption info banners — usable from casts to highlight key moments - C4 m:n convenience command (ADR-0045): `add m:n relationship … via <junction>` - ADR-0046 UI: width-derived schema sidebar + Ctrl-O nav mode, responsive two-row input + horizontal scroll, geometry-fixed hint panel - X1 comprehensive logging sweep across worker/parser/app/persistence/runtime - FK fixes: compound-FK violation message names every column pair; inline FK referencing a compound PK points at the table-level form Merged clean — no conflicts (the docs/website/ ADR namespace split kept the new main ADRs 0045–0047 from colliding). Tests on the merged tree: 2290 passed, 0 failed (1 ignored doctest, inherited from main).
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
# ADR-0045: `create m:n relationship` convenience command (C4)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-10); **implemented 2026-06-10**. Closes
|
||||
`requirements.md` **C4**.
|
||||
|
||||
**Implementation note — a corrected ADR premise.** The plan claimed
|
||||
"the walker already dispatches multiple nodes per entry word" and used
|
||||
that to justify a *separate* `CREATE_M2N` node. That is true only in
|
||||
**advanced** mode. The build hit **two** places hard-coded to assume
|
||||
**≤1 DSL form per entry word in simple mode**: (1) the dispatcher
|
||||
(`decide`) committed `simple.first()` unconditionally, so `create
|
||||
table` shadowed `create m:n`; (2) the completion continuation-merge was
|
||||
gated `if mode == Advanced`, so simple mode never surfaced `m:n` as a
|
||||
candidate. Both were generalized to support multiple DSL forms per
|
||||
entry word — **behaviour-preserving for every existing single-form
|
||||
command** (the dispatch reduces to the old single-candidate commit; the
|
||||
completion merge is gated on `simple_count > 1`). Verified: zero ripple
|
||||
beyond the new command's own surfaces. The teaching echo (advanced-mode
|
||||
DSL→SQL, ADR-0038) was also wired: `render_create_m2n` emits the
|
||||
generated `CREATE TABLE … FOREIGN KEY …` from the post-exec junction
|
||||
description (round-trips as valid SQL).
|
||||
|
||||
A second `/runda` DA pass (pre-commit) closed five coverage gaps
|
||||
(highlighting, persistence round-trip, junction rename, name-collision,
|
||||
missing-parent — the first two had been wrongly claimed verified) and
|
||||
found two more issues: (a) `create m:n … as __rdbms_*` was accepted —
|
||||
a hidden-orphan hole the new `as` slot **exposed**, but rooted in the
|
||||
simple-mode `TABLE_NAME_NEW` slot having no internal-name guard (so
|
||||
plain `create table __rdbms_*` had it too). Fixed at the **root** —
|
||||
a `reject_internal_table_name` guard in the shared `do_create_table`,
|
||||
closing every path (the advanced-SQL path already rejected at parse).
|
||||
(b) the usage disambiguator (`usage_key_for_input`) handled the `1:n`
|
||||
opener but not `m:n`, so `create m:n …` resolved to no usage form —
|
||||
fixed with an explicit `m:n` branch. All four
|
||||
design forks were escalated and user-confirmed at the recommended
|
||||
option (compound-over-FKs junction PK; `CASCADE` actions; auto-name +
|
||||
optional `as`; both modes). Two follow-up points were also confirmed
|
||||
in a `/runda` DA pass: **self-referential m:n is refused outright**
|
||||
(user: "refuse — full stop"; it is a beginner-facing convenience, not
|
||||
the place for directional-naming complexity), and the FK column naming
|
||||
is **`{parent_table}_{pk_column}`**. The DA pass additionally
|
||||
established — against the user's initial assumption — that **PK-less
|
||||
tables *are* reachable** (advanced-mode SQL `create table t (a int)`
|
||||
declares no PK; `sql_create_table.rs` asserts `pk.is_empty()`), so the
|
||||
parent-PK guard (D7) is retained as a correctness check.
|
||||
|
||||
Builds on ADR-0013 (named 1:n relationships, the relationship
|
||||
metadata table, the rebuild-table primitive), ADR-0043 (compound,
|
||||
list-based FK references — the junction may reference compound parent
|
||||
PKs), ADR-0011 (`Type::fk_target_type()` for FK column typing), and
|
||||
the existing `do_create_table` executor (which already accepts
|
||||
`foreign_keys: Vec<SqlForeignKey>` and writes relationship metadata
|
||||
per FK). Honours ADR-0003 (mode model), ADR-0009 (DSL conventions),
|
||||
ADR-0002 (no engine name in user-facing strings), and ADR-0024
|
||||
(unified grammar / `CommandNode` registration, completion, hints,
|
||||
help-id, usage-id wiring).
|
||||
|
||||
## Context
|
||||
|
||||
A many-to-many relationship is modelled in a relational database by a
|
||||
**junction table** (a.k.a. associative / bridge table) that holds one
|
||||
foreign key to each of the two parents. Today a learner can build this
|
||||
by hand: `create table` the junction, then `add 1:n relationship`
|
||||
twice. That is three commands and requires the learner to already know
|
||||
the junction-table pattern — exactly the concept C4 is meant to
|
||||
*teach*.
|
||||
|
||||
C4 (`requirements.md`): *"`create m:n relationship from <T1> to <T2>`
|
||||
produces an auto-named junction table the user can rename; pulls
|
||||
primary keys and FK definitions automatically."*
|
||||
|
||||
The relationship machinery this builds on is freshly solid: ADR-0043
|
||||
made the relationship model list-based (compound-aware) across six
|
||||
layers, and ADR-0044 gave relationships a visual representation. C4 is
|
||||
the natural convenience layer on top.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a DSL command:
|
||||
|
||||
```
|
||||
create m:n relationship from <T1> to <T2> [as <name>]
|
||||
```
|
||||
|
||||
It generates a **junction table** with one FK column per primary-key
|
||||
column of each parent, a **compound primary key** over all of those FK
|
||||
columns, and **two 1:n relationships** (junction → T1, junction → T2),
|
||||
all in a single transaction (= one undo step). The junction is a
|
||||
normal table: `rename table`, `drop table`, `show table`, `insert`,
|
||||
etc. all work on it afterward.
|
||||
|
||||
### D1 — Junction primary key: compound over the FK columns (fork, user-chosen)
|
||||
|
||||
The junction's PK is the **combination of all its FK columns**. For
|
||||
`create m:n relationship from Students to Courses` (both PK `id`):
|
||||
|
||||
```
|
||||
Students_Courses
|
||||
Students_id int ┐ PRIMARY KEY (Students_id, Courses_id)
|
||||
Courses_id int ┘
|
||||
FOREIGN KEY (Students_id) REFERENCES Students(id)
|
||||
FOREIGN KEY (Courses_id) REFERENCES Courses(id)
|
||||
```
|
||||
|
||||
This is the textbook junction: the `(Students_id, Courses_id)` pair is
|
||||
unique, so a student cannot be linked to the same course twice. It is
|
||||
the most pedagogically correct model and needs no surrogate key.
|
||||
|
||||
*Rejected:* a surrogate `serial` PK + `UNIQUE` over the FK pair (adds a
|
||||
key the learner did not ask for); two FK columns with no PK (allows
|
||||
duplicate links — wrong lesson).
|
||||
|
||||
### D2 — Referential actions: `CASCADE` on delete and update (fork, user-chosen)
|
||||
|
||||
Both generated FKs default to `ON DELETE CASCADE ON UPDATE CASCADE`. A
|
||||
junction row is meaningless without both ends, so deleting a parent
|
||||
(a Student or a Course) removes its link rows automatically — the
|
||||
natural junction semantics, and a clean teaching demonstration of
|
||||
cascade. There is no syntax in this command to override the actions;
|
||||
the learner who wants different actions builds the junction by hand
|
||||
(or drops + re-adds a relationship). This keeps the convenience
|
||||
command convenient.
|
||||
|
||||
### D3 — Naming: auto-name `<T1>_<T2>`, optional `as <name>` (fork, user-chosen)
|
||||
|
||||
The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`).
|
||||
An optional `as <name>` clause overrides it — consistent with
|
||||
`add 1:n relationship [as <name>]` and saving a follow-up
|
||||
`rename table`. The two generated relationships are auto-named by the
|
||||
existing relationship-name resolver (e.g.
|
||||
`Students_id_to_Students_Courses_Students_id`), exactly as `create
|
||||
table` with inline FKs already names them.
|
||||
|
||||
### D4 — FK column naming: `{parent_table}_{pk_column}`
|
||||
|
||||
Each FK column is named `{parent_table}_{pk_column}` — one per PK
|
||||
column of each parent. This disambiguates the common case where both
|
||||
parents share a PK column name (both `id` → `Students_id`,
|
||||
`Courses_id`), and generalises to compound parent PKs: a parent
|
||||
`Sections(course_id, term)` contributes `Sections_course_id` and
|
||||
`Sections_term`. Column types come from each parent PK column's
|
||||
`Type::fk_target_type()` (ADR-0011): `serial → int`, `shortid → text`,
|
||||
others identity — so the junction columns are plain storable types,
|
||||
never auto-generating.
|
||||
|
||||
### D5 — Mode availability: both simple and advanced
|
||||
|
||||
`create m:n relationship` is a `CommandCategory::Simple` DSL command,
|
||||
reachable in **both** input modes — the same posture as the sibling
|
||||
relationship commands (`drop relationship … Mode::Advanced` is a tested
|
||||
path today). There is no SQL spelling for it; an advanced-mode user who
|
||||
prefers raw SQL still has `CREATE TABLE` + `FOREIGN KEY`. The command
|
||||
is purely additive teaching sugar, so making it available everywhere is
|
||||
harmless and consistent.
|
||||
|
||||
### D6 — Implementation: one `do_create_table` call, not a batch
|
||||
|
||||
The junction table and both relationships are created by **building a
|
||||
single `Command`/worker request that `do_create_table` already
|
||||
handles**: `do_create_table` accepts `columns`, `primary_key`, and
|
||||
`foreign_keys: Vec<SqlForeignKey>`, and already inserts relationship
|
||||
metadata for each FK. So the executor:
|
||||
|
||||
1. Canonicalises `T1`, `T2` (`require_canonical_table`) and reads each
|
||||
parent's PK (`read_schema().primary_key`). **D7 — parent-PK guard:**
|
||||
if either parent's `primary_key` is empty, error with a friendly
|
||||
"`<table>` has no primary key, so it cannot anchor an m:n
|
||||
relationship" *before* building any FK. This is a real case — a
|
||||
table created via advanced-mode SQL `create table t (a int)` has no
|
||||
PK (`sql_create_table.rs` asserts `pk.is_empty()` for that form) —
|
||||
not a theoretical one, so the guard is a correctness requirement,
|
||||
not defensive padding.
|
||||
2. Builds the junction `ColumnSpec`s (one per parent PK column, typed
|
||||
via `fk_target_type`), the compound `primary_key` list, and two
|
||||
`SqlForeignKey` values (`on_delete = on_update = Cascade`).
|
||||
3. Calls the create-table path, which creates the table + both FKs +
|
||||
all metadata in **one transaction** — naturally one undo step, no
|
||||
`BeginBatch`/`EndBatch` bracketing needed.
|
||||
|
||||
This reuses the most-tested machinery and inherits its persistence,
|
||||
metadata, and FK-validation behaviour for free.
|
||||
|
||||
A new typed command variant `Command::CreateM2nRelationship { t1, t2,
|
||||
name }` carries the parsed form; the runtime/executor expands it to the
|
||||
junction definition. (We do **not** lower it to `Command::CreateTable`
|
||||
at parse time — keeping a distinct command preserves command identity
|
||||
per the X5 "unique commands for every unique case" principle, and lets
|
||||
the teaching echo speak in m:n terms.)
|
||||
|
||||
## Grammar, AST, and cross-cutting wiring
|
||||
|
||||
A new command is not done when it parses — it must light up every
|
||||
surface a learner touches. Enumerated here so none is missed
|
||||
(verification in **Testing**).
|
||||
|
||||
- **Grammar node** `CREATE_M2N` (`ddl.rs`): a separate `CommandNode`
|
||||
with `entry: create`, shape `m:n relationship [as <name>] from <T1>
|
||||
to <T2>`, registered in `REGISTRY` as `CommandCategory::Simple`. A
|
||||
separate node (not a branch inside `CREATE`) keeps the tested
|
||||
create-table builder untouched; the walker already dispatches
|
||||
multiple nodes per entry word. The `m:n` opener mirrors `1:n`
|
||||
(`Word("m")`, `Punct(':')`, `Word("n")`).
|
||||
- **AST builder** `build_create_m2n` → `Command::CreateM2nRelationship`.
|
||||
- **Command + worker plumbing:** `Command::CreateM2nRelationship`
|
||||
variant; `Request::CreateM2nRelationship`; runtime
|
||||
`execute_command_typed` arm; `Database::create_m2n_relationship`
|
||||
public API; executor `do_create_m2n_relationship` (per D6).
|
||||
- **Completion:** add `("m", "m:n")` to `COMPOSITE_CANDIDATES`
|
||||
(`completion.rs`) so Tab on `create m` offers `m:n` as one fluent
|
||||
piece (exactly as `("1", "1:n")` does for `add`). Identifier slots
|
||||
for `<T1>`/`<T2>` inherit table-name completion from the walker's
|
||||
`IdentSource::Tables` automatically.
|
||||
- **Hints:** set `HintMode`s on the `CREATE_M2N` nodes so the ambient
|
||||
hint panel guides `from` / table / `to` / table / optional `as`,
|
||||
matching the `add 1:n relationship` hinting.
|
||||
- **Highlighting:** automatic — `walker/highlight.rs` is grammar-driven
|
||||
with no per-command special-casing; verify the line highlights.
|
||||
- **Help:** `help_id: Some("ddl.create_m2n")` → the command appears in
|
||||
`help` automatically (REGISTRY iteration) and under `help create`;
|
||||
add the `help.ddl.create_m2n` catalog string.
|
||||
- **Usage / parse errors:** `usage_ids: &["parse.usage.create_m2n"]`
|
||||
→ a malformed `create m:n …` shows the form in the usage block; add
|
||||
the `parse.usage.create_m2n` catalog string. Add the near-miss cases
|
||||
to the `parse_error_pedagogy` matrix (ADR-0042) for the `create`
|
||||
entry word.
|
||||
- **Teaching echo** (`echo.rs` + `build_schema_echo`): a
|
||||
`CreateM2nRelationship` arm that echoes the generated junction —
|
||||
the `create table` it built plus the two `FOREIGN KEY` lines — so the
|
||||
learner sees the pattern the convenience expanded to.
|
||||
- **Structure render:** the executor returns the junction's
|
||||
`TableDescription`; the ADR-0044 render path already draws its
|
||||
relationships as diagrams on the create echo? No — incidental
|
||||
`create table` echoes keep prose (ADR-0044 reach); the m:n echo shows
|
||||
the junction structure with its outbound FKs in the standard prose
|
||||
form. (A future enhancement could draw both relationships as
|
||||
diagrams; out of scope here.)
|
||||
|
||||
## Genuine forks (escalated, all resolved 2026-06-10)
|
||||
|
||||
1. **Junction PK** — compound-over-FKs (chosen) vs surrogate serial +
|
||||
UNIQUE vs no PK. → D1.
|
||||
2. **Referential actions** — `CASCADE` (chosen) vs `NO ACTION` vs
|
||||
`RESTRICT`. → D2.
|
||||
3. **Naming** — auto-name + optional `as` (chosen) vs auto-name only.
|
||||
→ D3.
|
||||
4. **Mode** — both (chosen by default, unobjected) vs simple-only. → D5.
|
||||
|
||||
## Testing
|
||||
|
||||
Integration (`tests/it/`), test-first:
|
||||
|
||||
- **Functional:** `create m:n relationship from A to B` creates table
|
||||
`A_B` with the two FK columns, compound PK over them, and two
|
||||
enforced FKs; inserting a junction row with a non-existent parent is
|
||||
refused; the `(fk1, fk2)` pair is unique (duplicate link refused).
|
||||
- **`as <name>`** overrides the junction name.
|
||||
- **Compound parent PK:** a parent with a 2-column PK contributes two
|
||||
FK columns; the junction PK spans all of them; FKs enforce per pair.
|
||||
- **Cascade:** deleting a parent row removes its junction rows.
|
||||
- **Undo:** one `create m:n` is exactly one undo step (table + both
|
||||
relationships gone after `undo`).
|
||||
- **Persistence round-trip:** the junction + both relationships survive
|
||||
a save → rebuild-from-text.
|
||||
- **Errors:** missing parent table; parent without a PK; junction-name
|
||||
collision with an existing table; (self-m:n → OOS error, below).
|
||||
|
||||
Cross-cutting (the surfaces a new command must light up):
|
||||
|
||||
- **Completion:** Tab after `create ` surfaces `m:n relationship`;
|
||||
table-name completion fires at the `<T1>`/`<T2>` slots.
|
||||
- **Help:** `help` lists the command; `help create` includes the m:n
|
||||
form; the catalog string renders.
|
||||
- **Usage / parse pedagogy:** a bare/half `create m:n` shows the usage
|
||||
block; near-miss matrix entries added (`parse_error_pedagogy`).
|
||||
- **Hints + highlighting:** ambient hint progression through the form;
|
||||
the line highlights (snapshot or assertion as the sibling commands
|
||||
use).
|
||||
|
||||
All tiers green, zero skips; clippy clean (nursery).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Self-referential m:n** (`from T to T`) — **refused outright**
|
||||
(user-confirmed, "full stop"): the two FK column sets would collide
|
||||
on `{T}_{pkcol}`, and directional disambiguation (`from_*`/`to_*`)
|
||||
is more complexity than this beginner-facing convenience warrants.
|
||||
The executor detects `t1 == t2` (on the canonical names) and errors
|
||||
with a friendly pointer ("an m:n relationship needs two different
|
||||
tables — to link a table to itself, add the junction by hand").
|
||||
Not a deferred follow-up; a deliberate non-goal.
|
||||
- **Per-relationship action overrides** in the command syntax (D2 fixes
|
||||
`CASCADE`); use a hand-built junction for other actions.
|
||||
- **Extra junction columns** (payload attributes on the link, e.g. an
|
||||
enrolment date) — add them afterward with `add column`.
|
||||
- **m:n visualization as diagrams** on the create echo (ADR-0044 reach
|
||||
keeps incidental create echoes in prose).
|
||||
- **Renaming the auto-generated *relationships*** (only the table is
|
||||
`as`-nameable); drop + re-add covers it.
|
||||
@@ -0,0 +1,556 @@
|
||||
# ADR-0046: Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20 / #21 / #23)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-10); **implemented 2026-06-10**, phased **A → B → C**
|
||||
(see *Decision — phasing*) across commits `9f5f76b` (DA1/DA2) · `e0b9470`
|
||||
(DA3) · `41bae99` (DA4) · `386627a` (DB1) · `94825d0` (DB2/DB4) ·
|
||||
`c9da6ff` (DC1/DC2/DC4) · `22bec61` (DC3 + DC2 refinement). Closes Gitea
|
||||
issues **#20** (hint-panel height jumpiness), **#21** (database-structure
|
||||
/ left-column improvements), and **#23** (long command input). Issue
|
||||
#23's own note ("handle after #21 is decided") is honoured: the input
|
||||
work is split so the part that depends on the sidebar's width budget
|
||||
lands with it.
|
||||
|
||||
Two decisions landed differently from the original draft and are
|
||||
recorded inline: the relationship data lives on **`App`, not
|
||||
`SchemaCache`** (DB2), and the navigation overlay clears **only the
|
||||
sidebar strip + a one-column gutter** (panels stay visible behind),
|
||||
not the whole area (DC2).
|
||||
|
||||
Builds on and honours: **ADR-0003** (the persistent Simple/Advanced
|
||||
mode model — navigation mode is *not* a third input mode, see DC1),
|
||||
**ADR-0027** (the input validity indicator's reserved 6 right columns —
|
||||
horizontal scroll and 2-line display preserve that reserve),
|
||||
**ADR-0044** (relationship visualization — the relationships panel
|
||||
renders the same `RelationshipSchema` data the `show relationship`
|
||||
diagram already consumes), **ADR-0013 / ADR-0043** (the
|
||||
`RelationshipSchema` model: name, parent/child tables, list-based
|
||||
compound columns, referential actions), **ADR-0015** (project file
|
||||
format — sidebar visibility is **session-only**, so the format is
|
||||
untouched), and **ADR-0002** (no engine name in user-facing strings).
|
||||
Preserves the **pure-render-from-`App`-state** invariant (CLAUDE.md):
|
||||
visual changes here are driven either by new `App` *state* fields
|
||||
(mutated in `update()`) or by pure *render-time* functions of the frame
|
||||
geometry (see State section); `update()` stays pure-sync.
|
||||
|
||||
**Requirements & issues touched (verified against `requirements.md`).**
|
||||
Evolves **S1** (the always-present three-region layout — the left items
|
||||
region becomes width-optional, DB1). **Overrides S2**, which planned
|
||||
additional element kinds as *nested* items in the tables list;
|
||||
relationships get their own panel instead (DB2/DB4 — see Genuine forks
|
||||
§11). **Corrects S4**: its "keyboard-toggleable hint area" was never
|
||||
implemented (no toggle keybinding exists in the code) and is not wanted
|
||||
— the hint panel became indispensable once completion moved into it
|
||||
(ADR-0022) — so the toggle phrase is struck from `requirements.md` and
|
||||
no toggle is added here. Extends **I1a** (single-line cursor editing →
|
||||
horizontal scroll, DA3) and honours **S6 / ADR-0027** (the 6-column
|
||||
validity-indicator reserve, DA3/DA4). The PageUp/PageDown context-rebind
|
||||
(DC3) does **not** regress **V4**'s output scroll, which stays live in
|
||||
input mode. Adjacent but separate: Gitea **#22** (an in-app
|
||||
overlay/annotation layer for casts and guided lessons — its own ADR)
|
||||
shares the overlay-render and screencast context with DC2's `Clear`
|
||||
overlay; the two are meant to coexist, not merge.
|
||||
|
||||
## Context
|
||||
|
||||
Three UI issues were raised together because they are coupled through
|
||||
the terminal's width and height budget; treating them as one decision
|
||||
avoids three conflicting partial fixes.
|
||||
|
||||
**Current layout (verified in `src/ui.rs`).** `render()` splits
|
||||
vertically into `Min(8)` main / `Length(1)` project label / `Length(1)`
|
||||
status. The main area splits horizontally into a **fixed
|
||||
`Length(28)`** left column (`render_items_panel`, a "Tables" list with
|
||||
indented index names) and a `Min(20)` right column. The left column's
|
||||
block has `Borders::ALL`, so its **usable inner width is 26 columns**
|
||||
(28 − 2 borders). The right column splits vertically into output
|
||||
(`Min(5)`), input (`Length(3)`), and hint (`Length(hint_content)`).
|
||||
|
||||
**#20 — hint jumpiness.** `hint_content` is recomputed **every frame**
|
||||
as `clamp(wrapped_lines, 1, MAX_HINT_ROWS=3) + 2`, i.e. 3–5 rows. As the
|
||||
user types and hint strings appear, grow, and vanish, the hint panel
|
||||
resizes and **shoves the input and output panels**, producing the
|
||||
flicker visible in screencasts. The root cause is that height tracks
|
||||
*content* rather than terminal *geometry*.
|
||||
|
||||
The hint catalog (`src/friendly/strings/en-US.yaml`) was measured: the
|
||||
two longest strings are `value_literal_slot` (106 chars) and
|
||||
`create_table_element` (102); four more are 50–57; the rest ≤ 50. The
|
||||
wrapping consequence is sharp: at a right-column inner width ≥ ~54
|
||||
columns the worst string needs **at most 2 lines**; a **3rd line is
|
||||
only ever required when the right column is narrower than ~54** (a
|
||||
sub-~83-column terminal *with the sidebar shown*, or a sub-~55-column
|
||||
terminal). On the project's screencasts (90 columns wide, sidebar
|
||||
hidden — see DB1) two lines are provably sufficient.
|
||||
|
||||
**#21 — the left column.** A persistent 26-column Tables list is rarely
|
||||
filled even half-way by a teaching database, yet it permanently costs
|
||||
horizontal space the output and input panels want — acutely so on the
|
||||
90-column screencasts. The pedagogical value of an *always-visible*
|
||||
schema overview is real (CLAUDE.md "pedagogy wins ties"), so the column
|
||||
is **kept but made optional and more useful**, not deleted.
|
||||
|
||||
**#23 — long input.** The command input is a **single logical `String`**
|
||||
rendered by a `Paragraph` with no wrap and no horizontal scroll
|
||||
(`render_input_panel`); text past the panel width **clips silently**.
|
||||
The cursor is a byte offset on a char boundary; Up/Down drive history.
|
||||
The fix needs the width the sidebar's removal frees, hence the coupling.
|
||||
|
||||
**Keybinding space (verified).** Taken: Tab/Shift-Tab (completion),
|
||||
Enter (submit), Up/Down (history), Left/Right/Home/End (cursor),
|
||||
PageUp/PageDown (output scroll), Backspace/Delete, Esc
|
||||
(completion-undo / modal cancel), Ctrl-C (quit). Reserved-but-deferred
|
||||
(I1b readline): Ctrl-A/E/W/K/U. Printable keys all route to the input.
|
||||
Terminal-hijacked and therefore unusable: Ctrl-S/Q (flow control),
|
||||
Ctrl-Z (suspend), Ctrl-H (backspace), Ctrl-I/M (Tab/Enter), Ctrl-G
|
||||
(BEL). This leaves a narrow band of safe combinations for new controls.
|
||||
|
||||
## Decision — phasing
|
||||
|
||||
The work ships in three phases so the screencasts benefit from the
|
||||
least-controversial part first and the riskiest part (the focus/scroll
|
||||
model) is isolated:
|
||||
|
||||
- **Phase A — input & hint (DA1–DA4):** self-contained, no sidebar
|
||||
dependency; fixes #20 and the baseline of #23.
|
||||
- **Phase B — optional, richer sidebar (DB1–DB3):** visibility model +
|
||||
relationships panel + schema-cache enrichment.
|
||||
- **Phase C — navigation mode (DC1–DC4):** the Ctrl-O focus/scroll/
|
||||
expand model that makes the sidebar browsable.
|
||||
|
||||
Each phase is independently shippable and independently green.
|
||||
|
||||
## Decision — Phase A: responsive input & hint heights
|
||||
|
||||
### DA1 — Hint height is a function of terminal geometry, fixed between resizes
|
||||
|
||||
The hint panel's height is **decoupled from hint content**. It is
|
||||
computed from the terminal's width and height **once per resize** and
|
||||
held constant as the user types. Because the panel no longer resizes on
|
||||
every keystroke, it never shoves the input/output panels — the #20 jump
|
||||
is eliminated at the source, not damped. Content that exceeds the fixed
|
||||
height is ellipsized (the existing `clamp_wrapped` truncation), which is
|
||||
now a rare, width-driven event rather than a per-keystroke one.
|
||||
|
||||
### DA2 — Responsive height buckets
|
||||
|
||||
Heights are chosen by terminal **height** (rows), with the hint's
|
||||
optional 3rd line gated on right-column **width** (per the Context
|
||||
measurement):
|
||||
|
||||
| Terminal height | Input content rows | Hint content rows |
|
||||
| --- | --- | --- |
|
||||
| **Compact** (`H < 40` — covers the 25-row screencasts) | 1 (+ horizontal scroll, DA3) | 2 |
|
||||
| **Comfortable** (`H ≥ 40` — fullscreen terminals) | 2 (soft-wrap, DA4) | 2 (→ 3 only if right-column inner < ~54) |
|
||||
|
||||
A safety degradation protects tiny terminals: the output panel's
|
||||
`Min(5)` is honoured first; if rows are insufficient, the hint shrinks
|
||||
to 1, then the input to 1. The `40`-row threshold is a tunable constant.
|
||||
|
||||
### DA3 — Input horizontal scroll (single logical line)
|
||||
|
||||
The input keeps its **single-`String`** model (no embedded newlines —
|
||||
this is explicitly *not* multi-line input, see Out of scope). A new
|
||||
`App` field `input_scroll_offset: usize` tracks the first visible
|
||||
column; the renderer shows a window of the line and keeps the cursor in
|
||||
view, mirroring the candidate-line horizontal-scroll markers already in
|
||||
`render_candidate_line`. The ADR-0027 6-column indicator reserve is
|
||||
preserved (the scroll window is the text area = `inner.width − 6`, not
|
||||
the full inner width). Because `update()` does not know the panel width,
|
||||
the renderer feeds it back via a `note_input_viewport(text_width)` call
|
||||
(the analogue of the existing `note_output_viewport`), against which the
|
||||
offset is clamped to keep the cursor visible. `input_scroll_offset`
|
||||
**resets to 0** whenever the buffer is replaced wholesale — on `submit`,
|
||||
on history navigation (Up/Down), and on any clear. This is the baseline
|
||||
#23 fix and is sufficient on its own for the compact (1-row) layout.
|
||||
|
||||
### DA4 — Two-line input display when tall (`H ≥ 40`)
|
||||
|
||||
On comfortable terminals the input renders across **2 visual rows** by
|
||||
soft-wrapping the single logical line, with the cursor mapped to a
|
||||
(row, col) within the two rows. Content longer than two rows scrolls
|
||||
the two-row window horizontally (DA3) so the cursor stays visible. The
|
||||
**ADR-0027 `[ERR]`/`[WRN]` indicator stays anchored to the right edge
|
||||
of the *first* row** (its 6-column reserve applies to row 1; the soft-
|
||||
wrap on row 1 stops 6 columns short, row 2 uses the full text width) —
|
||||
S6 is preserved.
|
||||
|
||||
This is display-only over the same single-`String` model — distinct
|
||||
from the deferred true multi-line-input feature (I1, which adds
|
||||
*multiple logical lines* with Enter-inserts-newline). **Forward-compat
|
||||
note:** I1, when built, should reuse DA4's row-rendering and cursor
|
||||
(row, col) mapping rather than introduce a parallel one — DA4 is the
|
||||
substrate, not a competitor.
|
||||
|
||||
## Decision — Phase B: optional, richer sidebar
|
||||
|
||||
### DB1 — Width-derived visibility plus transient peek (session-only)
|
||||
|
||||
Sidebar visibility is **derived, not stored**: the sidebar is visible
|
||||
iff the terminal **width > 90** *or* navigation mode is currently
|
||||
focused on a sidebar panel (the Ctrl-O peek, DC1). It is recomputed
|
||||
every frame from terminal width and `NavFocus`; nothing persists to
|
||||
`project.yaml` (ADR-0015 untouched), so it is session-only by
|
||||
construction — and there is no stored visibility field to keep in sync.
|
||||
|
||||
At ≤ 90 columns the sidebar is hidden by default — so the 90-column
|
||||
screencasts never show it and the output panel gets the full width it
|
||||
needs there — but `Ctrl-O` temporarily reveals it for the duration of a
|
||||
browse and re-hides it on exit (DC1).
|
||||
|
||||
**No persistent show/hide toggle (resolved 2026-06-10, user).** Issue
|
||||
#21's original wording asked for "a keystroke to show and hide it"; the
|
||||
Ctrl-O peek covers that need, so no separate toggle and no
|
||||
force-shown/force-hidden override is added. Visibility stays a pure
|
||||
function of `(terminal width, NavFocus)` — the simplest model that
|
||||
satisfies the requirement. Should pinning ever prove necessary, a
|
||||
persistent override is an additive follow-up (see Out of scope).
|
||||
|
||||
### DB2 — Add a relationships panel; enrich the schema cache
|
||||
|
||||
The left column gains a **second panel** below Tables: a list of the
|
||||
project's relationships. This is a deliberate **override of S2**, whose
|
||||
note proposed additional element kinds (relations, views) as *nested*
|
||||
items inside the existing tables list. Relationships are *cross-table*,
|
||||
not per-table, so nesting them under a single table reads wrong; a
|
||||
sibling panel is the honest shape (user-confirmed 2026-06-10). S2's
|
||||
"without restructuring" intent is still met — the items column simply
|
||||
holds two stacked panels (DB4) instead of one.
|
||||
|
||||
The panel needs the full `RelationshipSchema` (name, parent/child
|
||||
tables, list-based columns, on-delete/on-update actions) that the `show
|
||||
relationship` path already fetches.
|
||||
|
||||
**Data home — `App`, not `SchemaCache` (revised at implementation,
|
||||
2026-06-10).** The design first proposed an additive
|
||||
`SchemaCache.relationship_details: Vec<RelationshipSchema>` field.
|
||||
Implementation revised this to a **parallel `App.relationships:
|
||||
Vec<RelationshipSchema>`** field for two reasons: (1) `SchemaCache` is
|
||||
*walker/completion-facing* — it needs only relationship **names**
|
||||
(unchanged in `SchemaCache.relationships`, still borrowed as
|
||||
`&Vec<String>` by `IdentSource::Relationships`); the full records are
|
||||
**UI-only**, so `App` is the architecturally correct home, mirroring
|
||||
`app.tables` (which the items panel already reads alongside the cache).
|
||||
(2) Adding a field to `SchemaCache` would force edits to ~23 full
|
||||
struct literals across the test suite, whereas `App` gains one field.
|
||||
The /runda guard it answered — *don't break completion by retyping
|
||||
`relationships`* — is fully honoured either way. Delivery: a worker
|
||||
`Request::ReadAllRelationships` (→ `Database::read_all_relationships`,
|
||||
returning `Vec<RelationshipSchema>` via the existing
|
||||
`read_all_relationships(conn)`); the runtime's `refresh_schema_cache`
|
||||
posts a new `AppEvent::RelationshipsRefreshed` alongside
|
||||
`SchemaCacheRefreshed`, and the `App` stores it. No behavioural
|
||||
difference from the original design.
|
||||
|
||||
The panel has **two display states** keyed off focus (DC2):
|
||||
|
||||
- **Unfocused (26-col)** — an ambient glance. Per relationship: the
|
||||
name (ellipsized past the inner width), and the endpoints broken at
|
||||
the arrow to fit a narrow column:
|
||||
|
||||
```
|
||||
Customers_Orders
|
||||
Customers.id ->
|
||||
Orders.customer_id
|
||||
```
|
||||
|
||||
- **Focused + expanded (40–50 col, DC2)** — a browse view. At the wider
|
||||
width the endpoints fit on one line
|
||||
(`Customers.id -> Orders.customer_id`); the arrow-break is used only
|
||||
when even the expanded width cannot hold a (possibly compound)
|
||||
endpoint pair. The wider width minimises horizontal truncation so the
|
||||
panel needs **mainly vertical scrolling** (DC3).
|
||||
|
||||
### DB3 — Sidebar width unchanged when unfocused
|
||||
|
||||
The unfocused sidebar keeps `Length(28)` / 26 inner columns. Widening
|
||||
happens only on focus (DC2), as an overlay, so the unfocused layout and
|
||||
the right-column reflow are unchanged from today.
|
||||
|
||||
### DB4 — Vertical split of the two left-column panels
|
||||
|
||||
The items column stacks **Tables (top)** and **Relationships (bottom)**.
|
||||
The Relationships panel's height is content-driven within bounds, so it
|
||||
stays small when there is little to show and never dominates the column
|
||||
(user-chosen 2026-06-10):
|
||||
|
||||
- **No relationships:** fixed at **5 rows** (3 content + 2 border),
|
||||
rendering a single `None` line. This is the floor.
|
||||
- **With relationships:** grows with content (`content_rows + 2`, where
|
||||
the unfocused format is ~3 rows per relationship) up to a **cap of
|
||||
50 % of the column height**; beyond the cap the panel **scrolls**
|
||||
(DC3). Formally `rel_h = clamp(content_rows + 2, 5, ⌊col_h / 2⌋)`.
|
||||
- **Tables** takes the remainder (`col_h − rel_h`) and scrolls if it
|
||||
overflows (it, too, is a focusable, scrollable panel — DC3).
|
||||
- **Degradation:** on a column too short to honour the 5-row floor plus
|
||||
a usable Tables panel (`col_h < ~10`), the floor yields first so
|
||||
Tables keeps at least its border + one row; both panels stay
|
||||
renderable. The `50 %` cap and `5`-row floor are tunable constants.
|
||||
|
||||
Heights are a pure render-time function of the column height and the
|
||||
cached relationship count, so they are unit-testable without a terminal
|
||||
(see Testing).
|
||||
|
||||
## Decision — Phase C: navigation mode
|
||||
|
||||
### DC1 — `Ctrl-O` navigation mode: a focus cycle, not an input mode
|
||||
|
||||
`Ctrl-O` enters a **navigation mode** that is orthogonal to the
|
||||
Simple/Advanced input mode (ADR-0003) — it changes *where keystrokes
|
||||
go*, not *how commands parse*. It drives a focus cycle:
|
||||
|
||||
1. **Press 1 →** focus the **Tables** panel (revealing the sidebar if
|
||||
it is currently hidden — a temporary peek).
|
||||
2. **Press 2 →** focus the **Relationships** panel.
|
||||
3. **Press 3 →** leave navigation mode: restore the sidebar width,
|
||||
re-hide it if the peek revealed it, and return focus to the command
|
||||
input.
|
||||
|
||||
`Esc` exits navigation mode directly from any focused panel (a
|
||||
short-cut for step 3); `Esc` is otherwise only completion-undo, which
|
||||
does not apply while browsing.
|
||||
|
||||
**Why `Ctrl-O` and not `Ctrl-B`.** `Ctrl-B` is the *default tmux prefix*
|
||||
and `Ctrl-A` is *screen's* — a multiplexer eats them before the app
|
||||
sees them, so either would make navigation mode unreachable for the many
|
||||
students who run inside tmux/screen. `Ctrl-O` is not a multiplexer
|
||||
prefix; in the raw mode the TUI sets, its legacy line-discipline meaning
|
||||
(discard-output) is disabled, so it reaches the app. It is free in the
|
||||
app today (the main key handler's catch-all, `app.rs:1001`). The
|
||||
mnemonic is weak ("**O**utline"); reachability won over mnemonic.
|
||||
|
||||
**Routing.** Navigation mode is handled inside the **main** key handler,
|
||||
which runs only when no modal is open (`app.rs:919` gates on
|
||||
`self.modal.is_some()`). So `Ctrl-O` and the nav keys are **inert while
|
||||
a modal dialog is active** — modals keep full keyboard ownership. Within
|
||||
the main handler, a `NavFocus != Input` branch precedes the normal
|
||||
input-editing arms and routes keys per DC3/DC4.
|
||||
|
||||
### DC2 — Expand-on-focus as an overlay
|
||||
|
||||
A focused sidebar panel widens to a **45-column** overlay
|
||||
(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded
|
||||
panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and
|
||||
paints the wide panel on top. The output/input/hint panels underneath
|
||||
keep their exact layout — **unused and unchanging** while browsing,
|
||||
**still visible to the right** of the overlay (just partially occluded
|
||||
on the left) — and are restored fully by the next frame on exit. The
|
||||
gutter keeps them from butting against the expanded panel's border so
|
||||
the overlay edge reads cleanly. This is cheap because the renderer is a
|
||||
pure function of `App` state: focus state selects the width and the
|
||||
overlay path. (The input underneath is inactive in navigation mode.)
|
||||
|
||||
*Implementation note (2026-06-10):* a full-area clear (hiding the base
|
||||
panels entirely during browse) was tried first and rejected — leaving
|
||||
the base visible is truer to "underneath keep their layout," and the
|
||||
one-column gutter resolves the only wrinkle (the panels' left edges
|
||||
being cut by the overlay reading harshly without separation).
|
||||
|
||||
### DC3 — Scroll the focused panel; focus highlight
|
||||
|
||||
While a sidebar panel is focused it scrolls, reusing the output panel's
|
||||
proven mechanism (a `usize` offset clamped against a renderer-reported
|
||||
viewport via a `note_*_viewport` call):
|
||||
|
||||
- **Up / Down — line-by-line** scroll (the lazygit `j`/`k` feel;
|
||||
user-chosen 2026-06-10).
|
||||
- **PageUp / PageDown — page** scroll.
|
||||
|
||||
This is a context-sensitive rebind: Up/Down drive *history* and
|
||||
PageUp/PageDown scroll the *output* in input mode, whereas in navigation
|
||||
mode they scroll the *focused sidebar panel*. The two contexts never
|
||||
apply simultaneously (`NavFocus` selects which). The focused panel shows
|
||||
an **accent border** so it is obvious where keys are going (lazygit
|
||||
convention).
|
||||
|
||||
### DC4 — Other keys are inert in navigation mode
|
||||
|
||||
The command input is visibly occluded by the overlay while browsing, so
|
||||
keys that have no navigation meaning are **inert** rather than acting on
|
||||
the hidden input. Specifically, **only** `Ctrl-O` (advance focus),
|
||||
Up/Down + PageUp/PageDown (scroll, DC3), and `Esc` (exit) are live;
|
||||
printable characters, Enter, Tab, Backspace/Delete, Left/Right, and
|
||||
Home/End all do nothing until navigation mode is exited. The occlusion
|
||||
signals "not typing," so swallowing these is clearer than letting them
|
||||
silently edit an invisible buffer.
|
||||
|
||||
## State, keybindings, and cross-cutting wiring
|
||||
|
||||
**Stored `App` state** (mutated in `update()`, read by the renderer):
|
||||
|
||||
- `input_scroll_offset: usize` (DA3) — reset on submit / history-nav /
|
||||
clear.
|
||||
- `NavFocus { Input, SidebarTables, SidebarRelationships }` (DC1) — the
|
||||
navigation-mode focus cursor; `Input` ≙ not in navigation mode.
|
||||
- Per-panel scroll offsets for the Tables and Relationships panels, each
|
||||
clamped against a renderer-reported viewport (DC3), mirroring
|
||||
`output_scroll` / `note_output_viewport`.
|
||||
- **`App.relationships: Vec<RelationshipSchema>`** (DB2) — the full
|
||||
relationship records for the sidebar panel, delivered by
|
||||
`AppEvent::RelationshipsRefreshed` from the runtime's schema refresh.
|
||||
`SchemaCache.relationships: Vec<String>` (names, for completion) is
|
||||
unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.)
|
||||
|
||||
**Render-time derived** (pure functions of `frame.area()` + cached
|
||||
counts — *not* stored fields; this keeps the pure-render invariant and
|
||||
makes the geometry logic unit-testable without a terminal):
|
||||
|
||||
- Sidebar visibility — `(width > 90) || NavFocus is a sidebar panel`
|
||||
(DB1).
|
||||
- Input/hint row counts — a pure helper `panel_heights(area) ->
|
||||
(input_rows, hint_rows)` (DA1/DA2), the same helper the renderer and
|
||||
the Tier-1 tests call.
|
||||
- Left-column split `rel_h = clamp(content_rows + 2, 5, ⌊col_h/2⌋)`
|
||||
(DB4).
|
||||
- Input width fed back to `update()` via `note_input_viewport`
|
||||
(DA3), since `update()` cannot read `frame.area()`.
|
||||
|
||||
Keybindings introduced/affected:
|
||||
|
||||
| Key | Input mode | Navigation mode |
|
||||
| --- | --- | --- |
|
||||
| `Ctrl-O` | enter nav mode, focus Tables (peek-reveal) | advance focus (Tables → Relationships → exit) |
|
||||
| `Up` / `Down` | history (unchanged) | line-scroll focused panel |
|
||||
| `PageUp` / `PageDown` | scroll output (unchanged) | page-scroll focused panel |
|
||||
| `Esc` | completion-undo (unchanged) | exit nav mode directly |
|
||||
| printable / Enter / Tab / Backspace / Left / Right / Home / End | edit/submit input (unchanged) | inert |
|
||||
|
||||
All nav keys are inert while a modal is open (the main handler is gated
|
||||
on `!modal.is_some()`, `app.rs:919`).
|
||||
|
||||
Renderer changes (`src/ui.rs`): geometry-driven hint/input height
|
||||
(DA1/DA2), input window + cursor windowing (DA3) and 2-row soft-wrap
|
||||
with row-1 indicator (DA4), the relationships panel + two-panel split
|
||||
(DB2/DB4), the focus accent border and expand-on-focus `Clear` overlay
|
||||
(DC2/DC3); `note_input_viewport` feedback added alongside the existing
|
||||
`note_output_viewport`.
|
||||
|
||||
## Genuine forks (escalated, resolved 2026-06-10)
|
||||
|
||||
1. **Left column fate** — remove entirely vs narrow vs **keep + make
|
||||
optional and richer** (chosen, user). → DB1/DB2.
|
||||
2. **Focus/scroll model** — a navigation mode (chosen, user) vs
|
||||
modeless modifier-key scroll vs deferring scroll. → DC1.
|
||||
3. **Navigation shortcut** — **`Ctrl-O`** (chosen, user); `Ctrl-B`
|
||||
*rejected on review* (it is the default tmux prefix → unreachable
|
||||
inside tmux); Ctrl-T also viable; terminal-hijacked combos excluded.
|
||||
→ DC1.
|
||||
4. **Expand-on-focus rendering** — **overlay with `Clear`** (chosen,
|
||||
keeps the right panels unchanging) vs re-splitting the layout (would
|
||||
reflow output). → DC2.
|
||||
5. **Navigation-mode printables** — **ignore** (chosen, user) vs
|
||||
drop-to-input-and-type. → DC4.
|
||||
6. **Hint anti-jump** — **fix height to terminal geometry** (chosen)
|
||||
vs damping/hysteresis vs always-reserve-max. → DA1.
|
||||
7. **Height thresholds** — `H < 40` compact / `H ≥ 40` comfortable, with
|
||||
1/2 and 2/2 splits (chosen, user). → DA2.
|
||||
8. **Visibility persistence** — **session-only** (chosen, user) vs
|
||||
per-project in `project.yaml`. → DB1.
|
||||
9. **Persistent show/hide toggle** — **deferred** (chosen, user): the
|
||||
Ctrl-O peek covers #21's "keystroke to show and hide", so visibility
|
||||
stays width-derived with no override. → DB1.
|
||||
10. **Nav-mode Up/Down** — **line-scroll the focused panel** (chosen,
|
||||
user) vs leaving scroll to PageUp/PageDown only. → DC3.
|
||||
11. **Relationships placement** — **a separate sibling panel** (chosen,
|
||||
user — *overrides S2*) vs nesting relations inside the tables list
|
||||
per S2's documented extension model. → DB2/DB4.
|
||||
12. **Hint-area toggle (S4)** — **no toggle** (chosen, user): the hint
|
||||
panel is indispensable since completion moved into it; S4's stale
|
||||
"keyboard-toggleable" claim (never implemented) is struck from
|
||||
`requirements.md`. → Status (Requirements & issues touched).
|
||||
|
||||
## Testing
|
||||
|
||||
Tier-1 (`app.rs` pure `update()` unit tests), **Tier-2 (`insta`
|
||||
snapshots, `src/snapshots/`) for the visual surfaces** — this change is
|
||||
heavily render-side, so the geometry/format/overlay assertions belong in
|
||||
snapshots, not only behavioural tests — and Tier-3 integration.
|
||||
Test-first per CLAUDE.md. The geometry helpers (`panel_heights`, the
|
||||
DB4 split, visibility) are **pure functions** exercised directly in
|
||||
Tier-1 without a terminal.
|
||||
|
||||
Phase A:
|
||||
- **Hint anti-jump:** `panel_heights(area)` is invariant under changing
|
||||
hint content at a fixed terminal size (assert it does not change as
|
||||
`app.hint` varies); it *does* change across the `H < 40` / `H ≥ 40`
|
||||
boundary and the width-< 54 boundary.
|
||||
- **Height buckets:** compact → input 1 row / hint 2; comfortable →
|
||||
input 2 / hint 2 (3 only when right-column inner < ~54); tiny-terminal
|
||||
degradation honours output `Min(5)`.
|
||||
- **Input horizontal scroll:** a line longer than the panel keeps the
|
||||
cursor visible while moving Left/Right/Home/End; ADR-0027's 6-column
|
||||
reserve is intact; no characters are lost (buffer = full string);
|
||||
`input_scroll_offset` resets on submit / history-nav / clear.
|
||||
- **Two-line input:** at `H ≥ 40` a line wrapping to two rows renders
|
||||
both rows with correct cursor (row, col), the `[ERR]`/`[WRN]`
|
||||
indicator on row 1's right edge (Tier-2 snapshot); a longer line
|
||||
scrolls.
|
||||
|
||||
Phase B:
|
||||
- **Relationship data path:** `Database::read_all_relationships`
|
||||
returns full records through the worker thread (integration test, real
|
||||
DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates
|
||||
`App.relationships`; `SchemaCache.relationships` names are undisturbed
|
||||
(completion still resolves them).
|
||||
- **Relationships panel render (Tier-2):** empty → a single `None` line
|
||||
at the 5-row floor; the unfocused narrow format (name + arrow-break,
|
||||
ellipsis past inner width); a compound endpoint pair arrow-breaks
|
||||
correctly.
|
||||
- **Two-panel split (DB4):** `rel_h = clamp(content_rows + 2, 5,
|
||||
⌊col_h/2⌋)` — 5 when empty; grows with content; capped at 50 %;
|
||||
Tables takes the remainder; degrades sanely at `col_h < 10`.
|
||||
- **Width-derived visibility:** width ≤ 90 hides, > 90 shows, recomputed
|
||||
on resize (the peek interaction is covered under Phase C).
|
||||
|
||||
Phase C:
|
||||
- **Focus cycle:** `Ctrl-O` cycles Input → Tables → Relationships →
|
||||
Input; `Esc` exits directly; a peek-revealed sidebar re-hides on exit;
|
||||
a width-shown (> 90) sidebar stays shown on exit; `Ctrl-O` is inert
|
||||
while a modal is open.
|
||||
- **Expand overlay (Tier-2):** focusing widens to the expanded width;
|
||||
the underlying output/input/hint state is unchanged across enter/exit
|
||||
(no reflow); the focus accent border marks the focused panel.
|
||||
- **Scroll rebind:** in nav mode Up/Down line-scroll and PageUp/PageDown
|
||||
page-scroll the focused panel (clamped to its viewport); in input mode
|
||||
Up/Down still drive history and PageUp/PageDown still scroll output
|
||||
(no V4 regression); inert keys (printable/Enter/Tab/Backspace) do
|
||||
nothing in nav mode.
|
||||
|
||||
All tiers green, zero skips; clippy clean (nursery).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **True multi-line input (I1)** — Enter-inserts-newline / Ctrl-Enter-
|
||||
submits over a multi-logical-line buffer. DA3/DA4 keep a single
|
||||
logical line; this remains a separate, deferred feature.
|
||||
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
|
||||
not touched here.
|
||||
- **Cross-session sidebar persistence** — visibility is session-only
|
||||
(DB1); persisting it would amend ADR-0015.
|
||||
- **The output panel as a third navigation focus target** — navigation
|
||||
mode cycles the two sidebar panels only; output keeps its input-mode
|
||||
PageUp/PageDown scroll.
|
||||
- **Relationship search / filtering within the panel** — the panel is a
|
||||
scrollable list; no query box.
|
||||
- **Relationship rename / edit from the panel** — it is read-only;
|
||||
mutation stays with the DSL/SQL commands.
|
||||
- **A persistent show/hide toggle / force-shown override** (DB1,
|
||||
resolved deferred) — visibility is width-derived + Ctrl-O peek; a
|
||||
pin/force override is an additive follow-up if ever needed.
|
||||
- **A hint-area toggle (old S4 wording)** — not implemented today and
|
||||
not wanted (the hint panel is indispensable since completion moved in;
|
||||
fork §12). The stale "keyboard-toggleable" phrase is removed from S4.
|
||||
- **In-app overlay / keystroke-annotation layer (Gitea #22)** — a
|
||||
separate feature with its own ADR; DC2's `Clear` overlay is built to
|
||||
coexist with it, not to provide it.
|
||||
|
||||
## Accepted consequences
|
||||
|
||||
- **Width-threshold discontinuity.** Because `Auto` visibility flips at
|
||||
width 90 and the sidebar costs 28 columns, widening a terminal across
|
||||
the boundary (89 → 91) makes the *output* narrower (≈ 89 → 63 inner)
|
||||
as the sidebar appears. This is inherent to any width-gated auto-hide
|
||||
and is accepted: 90 is the screencast width, real terminals sit well
|
||||
to one side of it, and `Ctrl-O` peek covers the in-between case. The
|
||||
`90` threshold is a tunable constant.
|
||||
@@ -0,0 +1,418 @@
|
||||
# ADR-0047: Demonstration overlay layer — keystroke badges and step captions
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-06-10); **implemented 2026-06-11**, phased A→B→C (closes
|
||||
Gitea **#22**). Addresses Gitea **#22**. Builds the in-app
|
||||
overlay/annotation primitive that screencast recording (ADR-website-001
|
||||
§2, the `autocast` pipeline) and a future guided-lesson system both
|
||||
need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must
|
||||
coexist with) and unblocks the polished version of the assistive-editor
|
||||
and projects (`#24`) casts.
|
||||
|
||||
**Implementation (commits `f879d54` → `2d0f4b2`).** Phase A
|
||||
(`f879d54`): `--demo` flag + `RDBMS_PLAYGROUND_DEMO` env →
|
||||
`App.demo_mode`, mirroring the `--no-undo` plumbing; help text mentions
|
||||
only the visible badges (the `Ctrl+]` caption trigger stays
|
||||
low-profile, D6). Phase B (`2584e76`): automatic keystroke badges — pure
|
||||
`demo_badge_label`, set in `App::update` before the modal gate, expired
|
||||
by a ~1.5 s runtime timer via the new `nearest_deadline` helper that
|
||||
extends the time-boxed-`recv` arm condition **without** regressing the
|
||||
ADR-0027 indicator debounce (the rewrite tracks `Instant` deadlines;
|
||||
verified equivalent). Phase C (`241f60c`): the stealth `Ctrl+]`
|
||||
caption buffer in `App::update`, intercepted before the modal gate so
|
||||
captions work over the load picker. Post-build (`2d0f4b2`, user
|
||||
decision): the overlays render as **flat filled yellow rectangles** (no
|
||||
border glyphs, one-cell text margin) to read as a distinct callout. A
|
||||
whole-implementation `/runda` pass returned **PASS** with no blockers;
|
||||
the only untested wiring is the `run_loop` badge timer (not unit-testable
|
||||
in isolation — same posture as the existing `IndicatorDebounce`; the
|
||||
pure pieces are all tested). One intentional, user-acknowledged
|
||||
behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is,
|
||||
by spec; exit capture with `Ctrl+]`). Tests: 2290 passing / 0 failing /
|
||||
0 skipped (Tier-1 label fn + caption FSM + `nearest_deadline`, Tier-2
|
||||
dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style,
|
||||
CLI parse/env); clippy clean.
|
||||
|
||||
All primary forks and the visual placement were **user-confirmed** —
|
||||
including the two follow-ups settled after the first draft: the trigger
|
||||
key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over
|
||||
`Ctrl+!` which autocast cannot send) and caption sizing (**wrap to 3
|
||||
lines**). A `/runda` pass over this ADR ran before implementation and
|
||||
tightened it — its findings are folded in below (caption/badge
|
||||
interception placement, in-capture key disposition, badge suppression
|
||||
during capture, the timer arm-condition, box clamping, the new
|
||||
output-rect field, and the control-code decode note).
|
||||
|
||||
**Requirements traceability.** There is **no `requirements.md` item**
|
||||
for this work — verified by sweep. It is tracked as Gitea issue **#22**
|
||||
plus this ADR, consistent with the project's convention ("issues are
|
||||
the lightweight tracker; ADRs are the decisions"). The website-side cast
|
||||
scope lives in **ADR-website-001** (website branch), not main's
|
||||
`requirements.md`.
|
||||
|
||||
## Context
|
||||
|
||||
The website records its demos as asciinema `.cast` files driven by
|
||||
**`autocast`** (ADR-website-001 §2; STYLE.md): source step-lists in
|
||||
`casts-src/casts.mjs` (`type` / `wait` / `key`) expand to **one key per
|
||||
character, Enter = `^M`**, recorded against the real `target/debug`
|
||||
binary. The hard constraint — the same one that drove `#24` — is that
|
||||
autocast can only emit **typeable characters, ASCII control codes
|
||||
(`^X`), and waits**. It cannot send arrow keys, function keys, or any
|
||||
multi-byte escape sequence.
|
||||
|
||||
Two classes of on-screen event are therefore invisible or
|
||||
unexplained in a cast:
|
||||
|
||||
1. **Keystrokes that cause a visible change but render no glyph of
|
||||
their own** — most acutely **Tab** completion: the command line
|
||||
jumps from `show data bo` to `show data books` with no sign a key
|
||||
was pressed. Enter, the arrows, Ctrl-O, Esc are the same.
|
||||
2. **Step structure / "what just happened" narration** — a cast is a
|
||||
silent moving picture; there is no channel to separate or explain
|
||||
steps for a visual learner.
|
||||
|
||||
asciinema-player has no inline keystroke overlay, and a website-side
|
||||
HTML overlay layered on the player would be fragile (its timings would
|
||||
have to track every recording and break on each re-record). The robust
|
||||
place to solve this is **in the app**: if the app renders the overlay,
|
||||
the cast captures it natively and it re-records for free. The same
|
||||
primitive is exactly what a future **guided-lesson** system needs to
|
||||
point at things and narrate steps — so it is built as a general
|
||||
capability, not a cast-only hack (the issue's "pays off twice"). It is
|
||||
also directly useful for a **teacher demonstrating the playground
|
||||
live** — pressing Tab in front of a class has the same
|
||||
invisible-keystroke problem as a cast.
|
||||
|
||||
The app's renderer is a pure function of `App` state and already draws
|
||||
two kinds of last-pass overlay over the base render with **no layout
|
||||
reflow**: modals and the ADR-0046 nav-mode sidebar overlay. The event
|
||||
loop already **time-boxes `event_rx.recv()`** with a `tokio` timeout
|
||||
(the ADR-0027 `IndicatorDebounce`) and redraws when the timer elapses —
|
||||
the exact mechanism a self-expiring badge needs. These two existing
|
||||
seams make the feature cheap.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Activation: a `--demo` flag (+ env var), off by default
|
||||
|
||||
Demonstration mode is entered with a **`--demo`** CLI flag, or
|
||||
equivalently the **`RDBMS_PLAYGROUND_DEMO`** environment variable (set
|
||||
truthy) — mirroring the existing `--log-file` / `RDBMS_PLAYGROUND_LOG_FILE`
|
||||
pair. It combines freely with every other flag (`--resume`, `--mode`, a
|
||||
positional path); there are no exclusions.
|
||||
|
||||
When the flag is **off** (the default), none of the key handling or
|
||||
rendering below is active and the app behaves exactly as today — **zero
|
||||
footprint for real users** (R8). `autocast` sets the flag when it
|
||||
launches the binary; a teacher sets it on their own command line.
|
||||
|
||||
It is framed as a general **demonstration mode**, not "cast mode" — the
|
||||
honest name for what it does, and it reads sensibly in `--help`. The
|
||||
flag is documented in the CLI banner (one line); obscurity is not a
|
||||
security property here and a harmless opt-in flag is better surfaced
|
||||
than hidden. What stays "low-profile" (per #22) is that there is **no
|
||||
normal in-app command** for it and **no persistent on-screen indicator**
|
||||
(see D7) — so a cast frame is never polluted by a `[DEMO]` marker.
|
||||
|
||||
### D2 — Keystroke badges: automatic, app-detected
|
||||
|
||||
In demo mode the app shows a transient badge **automatically** whenever
|
||||
it handles one of a curated set of *otherwise-invisible* keys. The cast
|
||||
does nothing special — it presses the key it was going to press anyway,
|
||||
and the badge re-records for free. The set:
|
||||
|
||||
| Key | Badge | | Key | Badge |
|
||||
|-----|-------|-|-----|-------|
|
||||
| Tab | `[TAB]` | | Home | `[HOME]` |
|
||||
| Shift-Tab | `[SHIFT-TAB]` | | End | `[END]` |
|
||||
| Enter | `[ENTER]` | | PageUp | `[PGUP]` |
|
||||
| Esc | `[ESC]` | | PageDown | `[PGDN]` |
|
||||
| ↑ | `[UP]` | | Backspace | `[BKSP]` |
|
||||
| ↓ | `[DOWN]` | | Delete | `[DEL]` |
|
||||
| ← | `[LEFT]` | | Ctrl-O | `[CTRL-O]` |
|
||||
| → | `[RIGHT]` | | | |
|
||||
|
||||
Plain character keys render a glyph on the input line already, so they
|
||||
produce **no** badge (that is the definition of the set — "invisible"
|
||||
keys). The badge fires on **key press**, regardless of whether the key
|
||||
had an effect in the current state (e.g. `↑` with no history still shows
|
||||
`[UP]`): simpler, and the demo author controls the script. Badge text is
|
||||
bracketed ASCII (`[TAB]`) per the user's preference — renders on every
|
||||
terminal and is cast-safe, unlike the `⇥` glyph mocked earlier.
|
||||
|
||||
The label mapping is a **pure function** `demo_badge_label(&KeyEvent) ->
|
||||
Option<&'static str>` (Tier-1 testable). The badge **auto-expires on a
|
||||
timer** (D5).
|
||||
|
||||
### D3 — Step captions: a stealth, control-code-delimited input buffer
|
||||
|
||||
Caption text must arrive through typeable input only (R4). A **single
|
||||
toggle control code — `Ctrl+]`** (byte `0x1D`) drives a **stealth
|
||||
capture buffer**. `Ctrl+]` was chosen (over the bound `Ctrl-O`/`Ctrl-C`,
|
||||
the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the
|
||||
signal/flow-control codes Ctrl-\\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a
|
||||
plain letter chord like Ctrl-G) because it is **maximally non-obvious**
|
||||
— the classic telnet escape, almost never pressed by accident — while
|
||||
still being a single ASCII control byte autocast can emit. It has **no
|
||||
signal or flow-control baggage** and is **multiplexer-safe**. Note
|
||||
collision risk is already near-zero in casts (a fresh `--demo` binary
|
||||
sees only scripted keys); the obscurity mainly protects a live teacher
|
||||
from a stray trigger.
|
||||
|
||||
- First `Ctrl+]` **opens** capture. The command input line and the
|
||||
output are untouched. If a caption is already visible, opening clears
|
||||
it (you are starting a new annotation).
|
||||
- Subsequent typed characters **accumulate into the caption buffer
|
||||
invisibly** — they do **not** appear on the prompt, do not execute,
|
||||
and do not enter history. **`Backspace`** deletes the last buffered
|
||||
character. **Every other key while capturing — Enter, the arrows,
|
||||
Tab, … — is inert** (swallowed, no effect): only typing and `Ctrl+]`
|
||||
do anything.
|
||||
- A second `Ctrl+]` **commits** the buffer to the caption box (D4).
|
||||
An **empty** commit (toggle-toggle with nothing typed) clears any
|
||||
visible caption — the author's explicit dismiss.
|
||||
|
||||
Because nothing about the capture shows on the prompt, the caption
|
||||
"pops" into its box with no ugly typing artifact, while the caption text
|
||||
still lives **inline in `casts.mjs`** at the right spot (one source of
|
||||
truth, no separate notes file to keep ordered).
|
||||
|
||||
This is all keyboard-stream interpretation, so it lives in the
|
||||
pure-sync `App::update()` (Tier-1 testable) and is **only active in demo
|
||||
mode** — when off, `Ctrl+]` is inert and characters reach the input
|
||||
line normally.
|
||||
|
||||
**Placement in `handle_key` — before the modal gate (runda finding).**
|
||||
The capture interception (`Ctrl+]` and the accumulating characters)
|
||||
**and** the "clear a visible caption on the next keystroke" check sit at
|
||||
the **very top of `handle_key`, before the `self.modal.is_some()`
|
||||
gate** — *not* alongside the `Ctrl-O` handler, which is gated behind it.
|
||||
This is required so captions can be authored **while a modal is open** —
|
||||
specifically the load-picker, which is exactly the **projects / `#24`
|
||||
cast** (annotating "press j/k to move", with an `[ENTER]` badge as the
|
||||
selection is made). While capturing, the modal is frozen (capture
|
||||
swallows keys), which is the intended behaviour. `App` exposes
|
||||
`demo_capturing` so the runtime can read it (see D5).
|
||||
|
||||
The control-code path is sound end to end, verified against our
|
||||
crossterm (0.29, `event/sys/unix/parse.rs:110-113`): `autocast` emits
|
||||
`^]` = byte `0x1D`; crossterm decodes `0x1C..=0x1F` →
|
||||
`KeyCode::Char('4'..='7') + CONTROL`, so **`Ctrl+]` (0x1D) arrives in
|
||||
the app as `KeyCode::Char('5') + KeyModifiers::CONTROL`** — that is the
|
||||
pattern `handle_key` matches. (The same routine decodes `0x09`/`0x0D`/
|
||||
`0x1B`/`0x7F` to the named `Tab`/`Enter`/`Esc`/`Backspace` keys and
|
||||
`0x01..=0x1A` to `Ctrl+a..z`, so `0x1D` is unambiguously distinct.) The
|
||||
canonical way to produce it is **Ctrl+]**; on some layouts `Ctrl+5`
|
||||
yields the same byte. *(This is the Unix/Linux decode path — the
|
||||
cast-recording platform; crossterm's separate Windows backend would be
|
||||
confirmed by test if live `--demo` on Windows is exercised.)*
|
||||
|
||||
### D4 — Both overlays are floating boxes at the output panel's inner bottom-right
|
||||
|
||||
The badge and the caption both render as **floating, flat filled
|
||||
rectangles anchored to the inside of the output panel's bottom-right
|
||||
corner** (inset one cell from the panel's inner edge), drawn **last over
|
||||
the base render** — after modals, so they remain visible while the
|
||||
load-picker (the `#24` cast) or any modal is up, and with **no layout
|
||||
reflow** (consistent with the modal / nav-overlay precedent; honours
|
||||
R8).
|
||||
|
||||
**Flat rectangle, not a bordered box (user decision, post-build).** The
|
||||
overlays draw as a **solid yellow rectangle with no border glyphs** and
|
||||
a one-cell margin around the text — deliberately *unlike* the app's
|
||||
rounded-border panels, so they read as a distinct callout that "stands
|
||||
out nicely" rather than as another panel. Implemented with a borderless
|
||||
`Block` fill (the `paint_background` mechanism) plus a `Paragraph` inset
|
||||
into a one-cell `Margin`.
|
||||
|
||||
The top-level `render()` does not currently know the output-panel rect
|
||||
(it is computed inside `render_right_column`), so a **new field
|
||||
`App.last_output_area: Rect`** is set in `render_output_panel` and read
|
||||
at the top-level draw pass to anchor the overlay — the established
|
||||
"renderer reports metrics back to `App`" pattern (sibling to
|
||||
`note_output_viewport`, which stores row counts, not a rect).
|
||||
|
||||
When **both** are present, the **keystroke badge stacks directly above
|
||||
the caption box** (both right-aligned in the corner) so they never
|
||||
overlap.
|
||||
|
||||
**Styling — deliberately high-contrast:** **bold black text on a yellow
|
||||
fill** — hard to overlook, identical in light and dark themes (a fixed
|
||||
high-contrast pair centralised in `theme.rs`, not theme-derived).
|
||||
|
||||
**Caption sizing (user-confirmed).** The caption is **word-wrapped to at
|
||||
most 3 lines** within a content width of `min(40, output_inner_width −
|
||||
4)` columns, ellipsised beyond the third line. So the caption rectangle
|
||||
is **3–5 rows** tall (1–3 text rows + a one-cell margin top and bottom),
|
||||
its height varying with the text — a full sentence fits without forcing
|
||||
the author to split it, while the 3-line cap keeps it corner-sized. The
|
||||
**badge** rectangle is always a single short token (`[TAB]` …
|
||||
`[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text row + the margin),
|
||||
narrow.
|
||||
|
||||
**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows
|
||||
(5 caption + 3 badge); the output panel's inner height is only `Min(5)`,
|
||||
so on a short terminal they could exceed it. Both boxes are **clamped to
|
||||
the output inner area**: width to `output_inner_width`, the caption's
|
||||
wrap-line count reduced so the stack fits the available height (badge
|
||||
first — it is the time-critical one), and if a box cannot fit at all
|
||||
(pathologically small terminal) it is **not drawn** rather than
|
||||
overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the
|
||||
guard only protects a real user who runs `--demo` in a tiny window.
|
||||
|
||||
### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke
|
||||
|
||||
- **Keystroke badge:** auto-expires on a **time-based TTL**, default
|
||||
**1.5 s** (a single tunable constant; the user asked for 1–2 s). This
|
||||
matters for both media: in a cast the badge fades on its own so a
|
||||
trailing `wait` ends on a clean frame, and in live teaching the badge
|
||||
clears without the presenter needing another key. A new badge replaces
|
||||
the current one and resets the timer.
|
||||
- **Caption:** persists **until the next keystroke**, which clears it
|
||||
and is then processed normally (or until an explicit empty-`Ctrl+]`
|
||||
dismiss, or replacement by a new caption).
|
||||
|
||||
The timer reuses the runtime's existing time-boxed-`recv` pattern: the
|
||||
loop already arms a `tokio::time::timeout` for the indicator debounce.
|
||||
|
||||
**Arm-condition extension (runda finding).** Today the loop time-boxes
|
||||
`recv` **only while `debounce.is_armed()`** — and the debounce settles
|
||||
at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL.
|
||||
So the arm condition becomes **`debounce.is_armed() || badge_pending`**,
|
||||
and the loop waits on the **nearest deadline** of the two. On a wake it
|
||||
checks each independently: at the 1000 ms debounce deadline it settles
|
||||
the indicator **without clearing the badge**; at the 1500 ms badge
|
||||
deadline it clears the badge; then redraws. The pure "nearest deadline"
|
||||
computation is unit-testable on its own.
|
||||
|
||||
The badge's expiry `Instant` lives in the **runtime** (so `App` stays
|
||||
clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing
|
||||
out of `App`); `App.demo_badge: Option<&'static str>` is the render
|
||||
mirror, **set by the runtime** on a significant key and cleared on timer
|
||||
elapse.
|
||||
|
||||
**Badge suppression during capture (runda finding).** Because the
|
||||
runtime sets badges from the raw key independently of `App` state, it
|
||||
must **not** badge a key that capture swallowed (e.g. an inert `Tab`
|
||||
while a caption is being typed would otherwise flash `[TAB]` for a
|
||||
no-op). The runtime sets a badge only when **`!app.demo_capturing`**.
|
||||
|
||||
**Ownership note.** `demo_caption` is mutated inside `update()`
|
||||
(input-driven) while `demo_badge` is mutated by the runtime
|
||||
(timing-driven). This split is deliberate and mirrors the existing
|
||||
`input` (set in `update()`) vs `input_indicator` (set by the runtime
|
||||
from `IndicatorDebounce`) pair — not an inconsistency.
|
||||
|
||||
### D6 — Help text and strings
|
||||
|
||||
The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo`
|
||||
line. User-facing wording obeys the house rules (no engine name, no
|
||||
"DSL"): *"Demonstration mode — show on-screen badges for otherwise-
|
||||
invisible keys (Tab, Enter, …), for screencasts and live teaching."*
|
||||
|
||||
The help text **deliberately mentions only the visible badges, not the
|
||||
`Ctrl+]` step-caption mechanism** (user decision): the caption trigger
|
||||
stays low-profile, true to #22's "secret trigger" framing — a cast
|
||||
author or lesson script knows it; a casual `--help` reader is not
|
||||
pointed at it. Badge labels and the `[…]` chrome are fixed ASCII, not
|
||||
localised; caption content is author-supplied free text and likewise
|
||||
not a catalog string.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Scripted badges** (cast pushes each badge explicitly) — rejected:
|
||||
the app already sees every key, so automatic detection (D2) is more
|
||||
robust and re-records for free. *(User-confirmed.)*
|
||||
- **Typed hidden command for captions** (a secret-prefixed line) —
|
||||
rejected: the command is briefly visible being typed on the prompt.
|
||||
**Preloaded notes file + advance key** — rejected: a separate file
|
||||
that must stay ordered/in-sync with the cast. The **stealth buffer**
|
||||
(D3) is self-contained in the cast script *and* leaves the prompt
|
||||
clean. *(User-confirmed.)*
|
||||
- **Fixed-corner HUD badge / badge by the input line** — rejected in
|
||||
favour of a floating box at the output panel's bottom-right; **top
|
||||
banner / subtitle band** for captions — rejected in favour of the
|
||||
matching floating box. *(User-confirmed via mockups.)*
|
||||
- **A persistent `[DEMO]` status-bar marker** — rejected: it would show
|
||||
in every cast frame. Demo mode is silent except for the transient
|
||||
overlays (D7).
|
||||
- **Caption persists for a fixed time** (instead of until next
|
||||
keystroke) — noted as a one-constant change if the next-keystroke rule
|
||||
proves too eager in practice; the user chose next-keystroke.
|
||||
- **Trigger via `Ctrl+!` / a Kitty-protocol chord** — rejected: not
|
||||
representable as a single ASCII control byte, so autocast cannot send
|
||||
it (fails R4, the same wall as arrow keys). **`Ctrl+G` / a letter
|
||||
chord** — workable but less non-obvious; the user chose the
|
||||
maximally-obscure `Ctrl+]` from the valid single-byte set.
|
||||
- **Single-line ellipsised caption** — rejected in favour of wrap-to-3-
|
||||
lines so a full sentence fits. *(User-confirmed via mockups.)*
|
||||
|
||||
## Consequences
|
||||
|
||||
- A general overlay primitive exists that the cast pipeline uses now and
|
||||
the guided-lesson system can reuse later (`App.demo_caption` and the
|
||||
badge channel are the seam).
|
||||
- `autocast` casts gain a real Tab-completion moment, key indicators for
|
||||
the projects/`#24` round-trip, and step captions — all by adding
|
||||
`key: ^G` / `type:` / `key: ^G` and ordinary keys to `casts.mjs`, then
|
||||
re-running `pnpm casts`. No website-side overlay machinery.
|
||||
- Teachers get the same affordance live via `--demo`.
|
||||
- One new control-code binding (`Ctrl+]`) is consumed, but only inside
|
||||
demo mode — normal sessions are unaffected, so it does not encroach on
|
||||
the reserved readline chords (I1b).
|
||||
- The renderer must expose the output-panel rect to `App`; a small,
|
||||
pattern-consistent addition.
|
||||
|
||||
## Scope / non-goals (OOS)
|
||||
|
||||
- **Manual/scripted badge push** and **badges for plain character
|
||||
keys** — out; badges are automatic over the fixed invisible-key set.
|
||||
- **Configurable overlay styling or placement** — out; fixed
|
||||
black-on-yellow boxes at the output panel's bottom-right.
|
||||
- **The guided-lesson / tutorial system itself** — out (its own ADR);
|
||||
this ADR only builds the primitive it will reuse.
|
||||
- **Persisting demo mode across project switches / sessions** — out;
|
||||
it is a per-run flag.
|
||||
- **Localising caption content** — out; captions are author-supplied
|
||||
free text.
|
||||
- **Output-pane scroll-in-casts** and other arrow-only interactions —
|
||||
out (separate enhancement; same autocast limitation as noted in #24).
|
||||
|
||||
## Testing
|
||||
|
||||
Per ADR-0008 and the project's test discipline (test-first; green, no
|
||||
skips):
|
||||
|
||||
- **Tier 1 (`app.rs` units):** `demo_badge_label` mapping over the full
|
||||
key set **and** the no-badge cases (plain chars, `Ctrl+]`, `Ctrl-C`);
|
||||
the stealth-caption state machine — open on `Ctrl+]`; characters
|
||||
accumulate with the **input line unchanged**; `Backspace` edits the
|
||||
buffer; **non-typing keys inert while capturing**; commit sets the
|
||||
caption; empty commit clears; opening over a visible caption clears
|
||||
it; next keystroke clears a visible caption **then processes
|
||||
normally**; capture works **with a modal open** (caption set while the
|
||||
load-picker modal is up, picker state untouched); the **demo-off
|
||||
gate** (`Ctrl+]` inert, characters reach the input, no caption/badge
|
||||
state ever set); the pure "nearest deadline" helper.
|
||||
- **Tier 2 (insta snapshots, `ui.rs`):** badge box, caption box, both
|
||||
stacked, at 90×26 in light and dark — verifying the bottom-right
|
||||
anchor, the stack order, and the black-on-yellow styling; plus a
|
||||
short-terminal case exercising the clamp/skip guard.
|
||||
- **Tier 3 (integration):** `--demo` plumbs `app.demo_mode`; a
|
||||
significant-key event sets `app.demo_badge` and a swallowed key during
|
||||
capture does **not**; a `Ctrl+]` / type / `Ctrl+]` sequence sets
|
||||
`app.demo_caption` without touching `app.input`.
|
||||
- **CLI (`cli.rs` units):** `--demo` parses (mirrors `--no-undo`); the
|
||||
`RDBMS_PLAYGROUND_DEMO` env fallback; default-off.
|
||||
|
||||
**Honest coverage limit.** The badge **timer-expiry wiring** runs inside
|
||||
`run_loop` (terminal + db worker), which is not unit-testable in
|
||||
isolation; it is a thin reuse of the already-proven `IndicatorDebounce`
|
||||
time-boxed-`recv` path. We therefore test the **pure pieces**
|
||||
exhaustively (label fn, capture state machine, nearest-deadline helper)
|
||||
and assert plumbing via Tier-3, rather than over-claiming an integration
|
||||
test of the `tokio` timeout itself.
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -57,3 +57,6 @@ This directory contains the project's ADRs, recorded per
|
||||
- [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 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship <name>` (one full diagram), `show table <T>` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5)
|
||||
- [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from <T1> to <T2> [as <name>]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships
|
||||
- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec<String>`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears)
|
||||
- [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Session handoff — 2026-06-10 (61)
|
||||
|
||||
Sixty-first handover. Continues from handoff-60 (Gitea migration
|
||||
cleanup + V1 relationship visualization, ADR-0044). This session was
|
||||
a **list-trimming pass on "easy wins"**: it closed **X1**
|
||||
(comprehensive logging, full sweep) and both **T3 residuals** (the two
|
||||
ADR-0043 messaging-polish items). Four commits, all green, all
|
||||
user-confirmed.
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. **HEAD `5a33f2a`.** 4 commits this session
|
||||
(`a8ad0c6` → `5a33f2a`) on top of session-60's 5; push is the user's
|
||||
step.
|
||||
|
||||
**Tests: 2211 passing / 0 failing / 1 ignored** (lib 1588, it 431,
|
||||
typing_surface_matrix 192; the 1 ignored is the long-standing
|
||||
doc-test). **Clippy clean** (nursery, all targets). +4 over the
|
||||
handoff-60 baseline of 2207 (one test per residual at each of the
|
||||
enrichment + render layers, plus the two grammar/worker tests).
|
||||
|
||||
This session's commits:
|
||||
```
|
||||
5a33f2a fix(fk): compound-FK violation message names every column pair
|
||||
6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form
|
||||
0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1)
|
||||
a8ad0c6 feat(db): comprehensive logging across worker + executors (X1)
|
||||
```
|
||||
|
||||
## §2. X1 — comprehensive logging (closed, `[x]`)
|
||||
|
||||
The full-sweep instrumentation pass the "log liberally" standard
|
||||
called for. **~75 → 135 `tracing` sites** under a documented level
|
||||
discipline now living in the **`src/logging.rs` module doc** (read it
|
||||
before adding logs — it is the durable convention).
|
||||
|
||||
**Levels:** `error` = unrecoverable; `warn` = recoverable / fallback
|
||||
taken; `info` = low-volume lifecycle (worker start/exit, project
|
||||
open); `debug` = the bulk, one line per *executed* command + its
|
||||
decision points (off by default, opt-in `RDBMS_PLAYGROUND_LOG=debug`);
|
||||
`trace` = hot paths only (per-keystroke parse, per-key input).
|
||||
|
||||
**Where logs go (was a point of confusion):** always a **file**
|
||||
(stdout/stderr would corrupt the TUI). Path precedence: `--log-file`
|
||||
> `RDBMS_PLAYGROUND_LOG_FILE` > default `~/.rdbms-playground/
|
||||
playground.log` (append mode). Level filter is the *separate*
|
||||
`RDBMS_PLAYGROUND_LOG` env var, default `info`.
|
||||
|
||||
**Coverage by commit:**
|
||||
- `a8ad0c6` **db.rs** (26→67): entry-`debug!` on all 34 `do_*`
|
||||
executors (DDL/DML/relationship/index/read), matching the existing
|
||||
`do_sql_delete`/`do_run_select` style — so the route through
|
||||
*delegating* executors (e.g. `add_column` →
|
||||
`add_constrained_column_via_rebuild`) is visible in the log
|
||||
*sequence*. Decision-point logs: `rebuild_table_with_copy`
|
||||
begin/commit (+ FK-check-failure and `foreign_keys` re-enable
|
||||
failure as `warn`), `do_insert` autofill summary, `do_delete`
|
||||
cascade summary, `do_create_table` FK resolution. Worker
|
||||
start/exit `debug!`→`info!`.
|
||||
- `0a7612e` **rest**: `persistence/mod.rs` logs every yaml/CSV/history
|
||||
write (the silent-failure disk paths); `runtime.rs`
|
||||
`execute_command_typed` dispatch; `app.rs` submit /
|
||||
`dispatch_app_command` / ADR-0044 diagram-vs-prose render choice;
|
||||
`dsl/parser.rs` parse begin/outcome at **`trace`** (the
|
||||
`parse_command_inner` choke point — `completion.rs` re-parses
|
||||
per-keystroke, probing candidates in a loop, so `debug` would
|
||||
flood).
|
||||
|
||||
**Verification:** emission proven end-to-end through the *real* worker
|
||||
thread + real `logging::init` via two throwaway smoke tests (db path
|
||||
and persistence path), both since deleted. The DA-honest gap: a few
|
||||
internal read-only helpers (`do_find_rows_matching`,
|
||||
`do_read_relationships`, `do_list_names_for`) and the thin `*_request`
|
||||
wrappers are not *individually* instrumented — the wrappers delegate
|
||||
to logged executors (skipped to avoid double-logging), the helpers are
|
||||
low-value. Effective coverage is complete via logged entry points; it
|
||||
is not literally 44/44.
|
||||
|
||||
## §3. T3 residuals — both closed (ADR-0043)
|
||||
|
||||
Two messaging-only items carried since handoff-59 §4; FK
|
||||
correctness/enforcement was never affected.
|
||||
|
||||
**#1 — inline-FK arity wording (`6985a43`).** `col REFERENCES P(a,b)`
|
||||
referencing a compound PK gave the generic arity error. An inline
|
||||
column-level FK is single-column by construction, so it now points at
|
||||
the table-level form: *"an inline column reference can only name one
|
||||
column … Use the table-level form instead: `FOREIGN KEY (<columns>)
|
||||
REFERENCES P (a, b)`."* Mechanism: new **`inline: bool` on
|
||||
`SqlForeignKey`**, set by the single shared grammar builder
|
||||
`consume_fk_reference` (true for the inline path at `ddl.rs:1560`,
|
||||
false for table-level `1590` and `build_alter_fk`); threaded into
|
||||
`resolve_fk_parent_columns`, which tailors the arity-mismatch message
|
||||
when `inline && parent_key.len() > 1`. 6 construction sites total (2
|
||||
grammar + 1 ALTER delegate + 3 test literals) — hand-edited, **not**
|
||||
the scripted sweep handoff-59 §4 warned about. The bare inline form
|
||||
(`col REFERENCES P`, no parens) hits the same arity branch, so it is
|
||||
covered by the same code (tested via the explicit-parens form).
|
||||
|
||||
**#2 — compound-FK violation names every pair (`5a33f2a`).**
|
||||
`enrich_fk_violation` (`runtime.rs`) picked only `local_columns
|
||||
.first()` / `other_columns.next()`. It now gathers all pairs of the
|
||||
matched relationship and carries them **comma-joined in the existing
|
||||
single-column facts slots** (`column`, `parent_column`, `value`), so
|
||||
the headline reads *"no parent row in `Region` has `country, code` =
|
||||
`7, 8`."* No facts-model or catalog change — joined strings flow
|
||||
through the existing `{parent_column}`/`{value}` placeholders.
|
||||
Single-column behaviour is byte-identical (a one-element join is the
|
||||
element). **Known minor awkwardness:** the *verbose hint* interpolates
|
||||
`{parent_table}.{parent_column}` → `Region.country, code`, which reads
|
||||
a touch oddly; the headline is clean. A perfectly-formatted compound
|
||||
hint would need catalog work, out of scope for a messaging-polish
|
||||
residual — flagged, not fixed.
|
||||
|
||||
## §4. Remaining open landscape (unchanged except X1)
|
||||
|
||||
**Closed this session:** X1 → `[x]`; both T3 residuals (ADR-0043 fully
|
||||
wrapped — no residuals left).
|
||||
|
||||
**Still `[/]` / `[~]` / larger (design-first, own ADR):**
|
||||
- **V2 / S3** multi-result tabs — output-model redesign.
|
||||
- **V3** whole-DB ER export; **V4** scrollable journal + Markdown
|
||||
(also the home for diagram live-reflow, ADR-0044 OOS-1).
|
||||
- **A1** app-commands — blocked on `seed` (SD1) + `hint` (H2).
|
||||
- **H1a** parse-error syntax help (partial; ADR-0021).
|
||||
- **DOC1** reference docs.
|
||||
|
||||
**`[ ]` not started:** H2 `hint`, SD1 `seed`, C4 m:n convenience, B3
|
||||
query-timeout, I1 multi-line input, I1b readline shortcuts, I5
|
||||
cancellation, **TT5 CI** (now Gitea Actions / Woodpecker — a fresh
|
||||
decision tied to the migration + ADR-0001's reopened distribution
|
||||
question), TT4 PTY (spec-only), D1–D3 distribution, NFR-1…7.
|
||||
|
||||
**ADR-0044 OOS for later:** OOS-7 user-configurable relationship-
|
||||
display setting (always-prose / always-diagram / auto-by-width).
|
||||
|
||||
## §5. Next job — candidates (by readiness)
|
||||
|
||||
No forced next step. Recommended order:
|
||||
1. **TT5 CI** — test infra is solid (2211 green) and now there is real
|
||||
logging to surface failures; no pipeline yet. A fresh **Gitea
|
||||
Actions / Woodpecker** decision (earns a short ADR; ties into
|
||||
ADR-0001's reopened distribution question). Highest leverage:
|
||||
protects everything else.
|
||||
2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1**
|
||||
app-commands; both are net-new, self-contained features (each its
|
||||
own ADR).
|
||||
3. **C4 m:n convenience** — auto-generate a junction table; depends on
|
||||
relationships, which are now solid (ADR-0043/0044 done).
|
||||
4. **V2/S3 tabs** or **V4 journal** — larger output-model redesign;
|
||||
design-first, own ADR. V4 also unlocks diagram live-reflow.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 59 → 60 → 61, then `CLAUDE.md` (Gitea/`tea` section),
|
||||
`docs/requirements.md` (X1 now `[x]`), `docs/adr/README.md`.
|
||||
2. **Before adding any logging:** read the level-discipline block in
|
||||
the `src/logging.rs` module doc (the X1 convention).
|
||||
3. **For FK/relationship work:** ADR-0043 (compound FKs) + ADR-0044
|
||||
(visualization) are both fully landed; `SqlForeignKey` now carries
|
||||
`inline`.
|
||||
4. Codebase on `main` at `5a33f2a`, clean, 9 commits unpushed (5 from
|
||||
session 60 + 4 this session).
|
||||
5. Process pins that paid off: **verify log emission end-to-end, not
|
||||
just that it compiles** (throwaway smoke tests through the real
|
||||
worker thread caught nothing broken but proved the stack);
|
||||
**hot-path logging belongs at `trace`, not `debug`** (the parser);
|
||||
**test-first on both residuals** (red → green at every layer);
|
||||
**hand-edit struct-field ripples, never script them** (handoff-59
|
||||
§4's scare avoided). Commits user-confirmed, append-only, no AI
|
||||
attribution.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Session handoff — 2026-06-10 (62)
|
||||
|
||||
Sixty-second handover. Continues from handoff-61 (X1 logging full sweep
|
||||
+ T3 residuals). This session was a **list-trimming + one-feature run**:
|
||||
it closed **C4** (the `create m:n relationship` convenience command,
|
||||
**ADR-0045**) and, in passing, resolved **Gitea issue #19** (drop-PK
|
||||
guard). Handoff-61 itself was written mid-session, so the X1 / T3 work
|
||||
it describes is also part of this session's commit range.
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. **HEAD `8bd43cc`.** Push is the user's step.
|
||||
|
||||
**Tests: 2237 passing / 0 failing / 1 ignored** (the 1 ignored is the
|
||||
long-standing doc-test). **Clippy clean** (nursery, all targets). +30
|
||||
over the handoff-60 baseline of 2207.
|
||||
|
||||
**This session's commits** (8, on top of session-60's 5):
|
||||
```
|
||||
8bd43cc feat: create m:n relationship convenience command (C4, ADR-0045)
|
||||
e598008 docs: ADR-0045 m:n convenience command (C4); accepted
|
||||
e44d298 test+docs: lock drop-PK-refused on advanced surface; document no-PK advanced mode (#19)
|
||||
b803468 docs: session handoff 61 — X1 logging full sweep + T3 residuals closed
|
||||
5a33f2a fix(fk): compound-FK violation message names every column pair
|
||||
6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form
|
||||
0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1)
|
||||
a8ad0c6 feat(db): comprehensive logging across worker + executors (X1)
|
||||
```
|
||||
|
||||
**Requirements closed this session:** **X1** `[x]` (logging), **T3**
|
||||
residuals (both ADR-0043 messaging items), **C4** `[x]` (m:n). Gitea
|
||||
**#19 closed**.
|
||||
|
||||
## §2. X1 — comprehensive logging (closed) — see handoff-61 §2
|
||||
|
||||
Full detail in handoff-61. In brief: ~75 → **137** `tracing` sites under
|
||||
a documented level discipline (read the **`src/logging.rs` module doc**
|
||||
before adding logs). Logs go to a **file** (`--log-file` >
|
||||
`RDBMS_PLAYGROUND_LOG_FILE` > `~/.rdbms-playground/playground.log`);
|
||||
level via the separate `RDBMS_PLAYGROUND_LOG` env (default `info`).
|
||||
`debug` = per-command detail (off by default), `trace` = hot paths
|
||||
(per-keystroke parse).
|
||||
|
||||
## §3. T3 residuals (both closed) — see handoff-61 §3
|
||||
|
||||
`6985a43` inline-FK arity wording (points at the table-level form;
|
||||
added `inline: bool` to `SqlForeignKey`). `5a33f2a` compound-FK
|
||||
violation names every column pair (comma-joined in the single-column
|
||||
facts slots; `enrich_fk_violation`). ADR-0043 now has no residuals.
|
||||
|
||||
## §4. Issue #19 — drop-PK guard (closed, `e44d298`)
|
||||
|
||||
A parallel check the user requested. **Finding: dropping a PK column is
|
||||
already refused in both modes** via the shared `do_drop_column` guard
|
||||
(*"cannot drop primary-key column …"*) — simple `drop column` and
|
||||
advanced `ALTER … DROP COLUMN` both route through it. Added end-to-end
|
||||
coverage (`tests/it/sql_alter_table.rs`: single + compound PK, refusal
|
||||
for the right reason). **Corrected a long-standing misconception:** the
|
||||
issue's premise ("we don't support creating a table with no PK") is true
|
||||
only in **simple** mode — advanced SQL `create table t (a int)` makes a
|
||||
real **PK-less** table (SQLite's implicit `rowid` keys it; only
|
||||
`WITHOUT ROWID` lacks one, which this app never creates). The simple-mode
|
||||
`with pk` requirement is **pedagogical** (ADR-0029), not an engine
|
||||
constraint. Documented in `docs/simple-mode-limitations.md`.
|
||||
|
||||
## §5. C4 — `create m:n relationship` (the feature, ADR-0045)
|
||||
|
||||
`create m:n relationship from <T1> to <T2> [as <name>]` generates a
|
||||
**junction table**: one FK column per parent PK column
|
||||
(`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a **compound
|
||||
PK** over all of them, and **two `CASCADE` 1:n relationships** — all in
|
||||
**one `do_create_table` call = one undo step** (no batch needed;
|
||||
`do_create_table` already takes `foreign_keys` + writes per-FK
|
||||
relationship metadata). Auto-named `{T1}_{T2}` (optional `as`), available
|
||||
in **both modes**, compound-parent PKs supported (ADR-0043).
|
||||
|
||||
**Forks (all user-confirmed):** compound-over-FKs PK (vs surrogate /
|
||||
none); `CASCADE` actions; auto-name + optional `as`; both modes; FK
|
||||
columns `{table}_{pkcol}`. **Refused:** self-referential m:n (`from T to
|
||||
T` — full stop, OOS); PK-less parent; internal `__rdbms_*` junction
|
||||
name; name collision.
|
||||
|
||||
**Where the code lives:**
|
||||
- Grammar: a **separate `CREATE_M2N` `CommandNode`** in
|
||||
`dsl/grammar/ddl.rs` (entry `create`, opener `Node::Literal("m")` —
|
||||
not a keyword, so it never shadows an identifier), registered Simple
|
||||
in `grammar/mod.rs` `REGISTRY`. `build_create_m2n` →
|
||||
`Command::CreateM2nRelationship { t1, t2, name }`.
|
||||
- Worker: `Request::CreateM2nRelationship`,
|
||||
`Database::create_m2n_relationship`, executor
|
||||
`do_create_m2n_relationship` (reads each PK, guards self-ref /
|
||||
PK-less, builds columns + compound PK + 2 `SqlForeignKey`s, calls
|
||||
`do_create_table`).
|
||||
- Runtime: `execute_command_typed` arm. Echo:
|
||||
`echo::render_create_m2n` (advanced-mode DSL→SQL teaching echo, ADR-
|
||||
0038 — the generated `CREATE TABLE … FOREIGN KEY …`, round-trips as
|
||||
valid SQL), wired in `build_schema_echo`.
|
||||
- Surfaces: completion `("m","m:n")` composite; `help.ddl.create_m2n` +
|
||||
`parse.usage.create_m2n` catalog (+ `keys.rs` declarations);
|
||||
highlighting is grammar-driven (automatic).
|
||||
|
||||
**Tests:** 14 integration (`tests/it/m2n.rs`), 7 typing-surface matrix
|
||||
(`tests/typing_surface/create_m2n.rs` — completion/hint/highlight/parse),
|
||||
plus echo / highlight / usage-disambiguator / internal-name units.
|
||||
|
||||
## §6. Framework fixes the C4 build + two `/runda` passes surfaced
|
||||
|
||||
C4's "separate node" design rested on an ADR premise that proved **only
|
||||
half true**: *"the walker already dispatches multiple nodes per entry
|
||||
word"* held in **advanced** mode but not **simple**. Three latent
|
||||
simple-mode assumptions ("≤1 DSL form per entry word") were generalized,
|
||||
**all behaviour-preserving for existing single-form commands**:
|
||||
|
||||
1. **Dispatch** (`walker/mod.rs` `decide`) committed `simple.first()`
|
||||
unconditionally → now tries simple candidates (so `create table` no
|
||||
longer shadows `create m:n`). Reduces to the old single-candidate
|
||||
commit when there is one.
|
||||
2. **Completion continuation-merge** (`walker/mod.rs`) was gated
|
||||
`if mode == Advanced` → now runs in simple mode too, **gated on
|
||||
`simple_count > 1`** so single-form entry words are untouched.
|
||||
3. **Usage disambiguator** (`grammar/mod.rs` `usage_key_for_input`)
|
||||
knew the `1:n` opener but not `m:n` → added an explicit branch.
|
||||
|
||||
Plus a **root-cause bug fix** (user-chosen scope): `do_create_table`
|
||||
now rejects internal `__rdbms_*` names. This closed both the C4 `as
|
||||
__rdbms_*` hole **and a pre-existing hole** — simple-mode DSL `create
|
||||
table __rdbms_*` was accepted at parse (the `TABLE_NAME_NEW` slot had no
|
||||
guard; only the advanced-SQL path rejected internal names). The shared
|
||||
executor is the single choke point; the SQL path still rejects earlier
|
||||
at parse.
|
||||
|
||||
**Process note:** the two `/runda` passes were worth it. The first
|
||||
(pre-build) corrected the inverted "no PK-less tables" assumption and
|
||||
confirmed the `do_create_table` reuse against code. The second
|
||||
(pre-commit) closed **five** test-coverage gaps — two of which
|
||||
(highlighting, persistence round-trip) had been **wrongly claimed
|
||||
verified** (the typing-surface `Assessment` has no highlight field;
|
||||
"transitively covered" was a hand-wave) — and found the two bugs above.
|
||||
Lesson re-confirmed: verify a claimed-tested surface actually has an
|
||||
assertion; "transitively covered" is a DA red flag.
|
||||
|
||||
## §7. Remaining open landscape
|
||||
|
||||
**Closed since handoff-60:** X1, both T3 residuals, C4, #19. ADR-0043 and
|
||||
ADR-0045 fully landed.
|
||||
|
||||
**Still open (by readiness, unchanged otherwise):**
|
||||
1. **TT5 CI** — test infra solid (2237 green); no pipeline. **Gitea
|
||||
Actions / Woodpecker** (a fresh decision tied to the migration +
|
||||
ADR-0001's reopened distribution question). **Friction:** the
|
||||
requirement is Linux/macOS/Windows on stable — self-hosted Gitea can
|
||||
do Linux easily, but mac/Windows runners need machines that may not
|
||||
exist; likely needs a Linux-first scope decision.
|
||||
2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1**
|
||||
app-commands; both net-new, own ADR (SD2 is the seed-generator design
|
||||
ADR). SD1 should now seed **m:n junctions** too (valid FK refs from
|
||||
parent rows) — C4 makes that concrete.
|
||||
3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model
|
||||
redesign, design-first, own ADR. V4 also unlocks diagram live-reflow.
|
||||
4. **C3a modify relationship** — small follow-up (drop+add covers it
|
||||
today; ADR pending).
|
||||
|
||||
**ADR-0045 OOS for later:** self-referential m:n (deliberate non-goal);
|
||||
per-relationship action overrides; extra junction payload columns;
|
||||
m:n-as-diagram echo. **Pre-existing, now-fixed:** the internal-name hole
|
||||
(§6) — no separate issue needed, it's closed.
|
||||
|
||||
## §8. How to take over
|
||||
|
||||
1. Read handoffs 60 → 61 → 62, then `CLAUDE.md`, `docs/requirements.md`
|
||||
(X1/C4 now `[x]`), `docs/adr/README.md`.
|
||||
2. **Before adding logging:** the level discipline in the
|
||||
`src/logging.rs` module doc.
|
||||
3. **For grammar/command work:** an entry word can now carry **multiple
|
||||
DSL forms** in simple mode (C4 generalized the dispatch + completion +
|
||||
usage paths). `create` is the first such entry word (table + m:n).
|
||||
4. **For relationship/FK work:** ADR-0013/0043/0044/0045 are all landed;
|
||||
`SqlForeignKey` carries `inline`; `do_create_table` now guards
|
||||
internal names.
|
||||
5. Codebase on `main` at `8bd43cc`, clean. Commits user-confirmed,
|
||||
append-only, no AI attribution. Process pins that paid off: **two
|
||||
`/runda` passes per feature** (design + pre-commit) — both found real
|
||||
bugs and gaps every time; **verify a claimed-tested surface has an
|
||||
actual assertion**; **escalate genuine forks** (every C4 design choice
|
||||
+ the internal-name fix scope was the user's).
|
||||
@@ -0,0 +1,159 @@
|
||||
# Session handoff — 2026-06-10 (63)
|
||||
|
||||
Sixty-third handover. Continues from handoff-62 (C4 m:n + #19). This
|
||||
was a **single-ADR, full-build session**: it designed and implemented
|
||||
**ADR-0046** end to end — the UI work for the three sidebar/input
|
||||
issues **#20 / #21 / #23**, all now **closed** on Gitea.
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. **HEAD `22bec61`** (plus an uncommitted docs
|
||||
finalization — ADR status flip + this handoff — see §7). Push is the
|
||||
user's step.
|
||||
|
||||
**Tests: 2263 passing / 0 failing / 1 ignored** (the 1 ignored is the
|
||||
long-standing doc-test). **Clippy clean** (nursery, all targets). +26
|
||||
over the handoff-62 baseline of 2237.
|
||||
|
||||
**This session's commits** (8 + the docs finalization):
|
||||
```
|
||||
22bec61 feat(ui): scroll the focused sidebar panel + refine the nav overlay (DC3 + DC2)
|
||||
c9da6ff feat(ui): Ctrl-O navigation mode — peek + expand the schema sidebar (DC1/DC2/DC4)
|
||||
94825d0 feat(ui): relationships sidebar panel + schema data (DB2/DB4)
|
||||
386627a feat(ui): width-derived sidebar visibility — hide at <=90 cols (DB1)
|
||||
41bae99 feat(ui): two-row input display on tall terminals (DA4)
|
||||
e0b9470 feat(ui): horizontal-scroll long input so the cursor stays visible (DA3)
|
||||
9f5f76b fix(ui): geometry-fixed hint-panel height kills the typing jump (DA1/DA2)
|
||||
93266b9 docs: ADR-0046 UI sidebar nav-mode + responsive input/hint
|
||||
```
|
||||
|
||||
**Issues closed:** **#20**, **#21**, **#23** (all via ADR-0046).
|
||||
**#22** (in-app overlay/keystroke-annotation layer for casts/lessons)
|
||||
remains **open** — its own future ADR; adjacent but out of scope here.
|
||||
|
||||
## §2. What shipped — ADR-0046 (read it; it's the source of truth)
|
||||
|
||||
Three coupled UI issues, treated as one decision because they share the
|
||||
terminal width/height budget. Phased A → B → C.
|
||||
|
||||
**Phase A — input & hint (#20, #23).**
|
||||
- **DA1/DA2 (#20):** the Hint panel height is now a pure function of
|
||||
terminal geometry (`hint_rows` → later `panel_heights`), **fixed
|
||||
between resizes** — it no longer resizes as you type, killing the
|
||||
jump. Compact (`<40` rows) = hint 2; comfortable = hint 2, or 3 only
|
||||
when the column is narrow (`inner < 54`). This **reverses issue #12's**
|
||||
shrink-to-content sizing (its two tests were replaced by an anti-jump
|
||||
invariant). Long hints ellipsize at the fixed budget.
|
||||
- **DA3 (#23):** long input **horizontally scrolls** to keep the cursor
|
||||
visible (`input_scroll_offset`, pure `input_scroll_offset()` helper),
|
||||
with muted `<` / `>` edge markers; resets on submit / history.
|
||||
Preserves ADR-0027's 6-col indicator reserve.
|
||||
- **DA4 (#23):** on a tall terminal (`>=40` rows) the input renders
|
||||
across **two visual rows** (soft-wrap of the single logical line;
|
||||
indicator stays on row 1). Distinct from deferred multi-line **I1**;
|
||||
`expand_runs_to_cells` is the substrate I1 should reuse.
|
||||
|
||||
**Phase B — the sidebar (#21).**
|
||||
- **DB1:** the left column is **width-optional** — `sidebar_visible() =
|
||||
width > 90`, so it's hidden at <=90 (the 90-col screencasts) and the
|
||||
right column takes the full width. (Resize a terminal below ~90 to see
|
||||
it; in a normal wide terminal it shows, by design.)
|
||||
- **DB2/DB4:** a **Relationships panel** stacks below Tables — each
|
||||
relationship is name + endpoints broken at the arrow
|
||||
(`Customers.id ->` / indented `Orders.customer_id`), ellipsized. The
|
||||
panel floors at 5 rows ("(none)") and grows to a 50%-of-column cap
|
||||
(`relationships_panel_height`). **Overrides S2** (relations were to be
|
||||
*nested* in the tables list; a sibling panel is the honest shape).
|
||||
|
||||
**Phase C — navigation mode (#21).**
|
||||
- **DC1/DC4:** **`Ctrl-O`** enters a navigation mode orthogonal to the
|
||||
input mode, cycling focus **Input → Tables → Relationships → Input**
|
||||
(`Esc` exits). It's routed in the main key handler *after* the modal
|
||||
gate, so it's inert behind a modal; in nav mode every non-nav key is
|
||||
inert (the input is occluded). `NavFocus` enum on `App`.
|
||||
- **DC2:** the focused panel is revealed (peek, even when width-hidden)
|
||||
and drawn as a **45-col expanded overlay**, clearing the sidebar strip
|
||||
**+ a one-column gutter** and leaving the base output/input/hint
|
||||
visible (unchanged) to the right. *(Two variants were eyeballed; this
|
||||
partial-clear-with-gutter was chosen over a full-area clear.)*
|
||||
- **DC3:** the focused panel **scrolls** — Up/Down by a line,
|
||||
PageUp/PageDown by its visible rows; per-panel offsets clamped to
|
||||
content at render time, mirroring the output-panel scroll.
|
||||
|
||||
**`Ctrl-B` was rejected** for nav mode (it's the tmux prefix →
|
||||
unreachable inside tmux); `Ctrl-O` is multiplexer-safe.
|
||||
|
||||
## §3. Two decisions that landed differently from the draft
|
||||
|
||||
Both recorded inline in the ADR (and called out in its Status):
|
||||
1. **Relationship data on `App`, not `SchemaCache`** (DB2). `SchemaCache`
|
||||
is walker/completion-facing and needs only relationship *names*
|
||||
(untouched); the full records are UI-only, so `App.relationships`
|
||||
mirrors `app.tables`, and it avoided editing ~23 `SchemaCache`
|
||||
literals. Delivered via `Database::read_all_relationships` (new worker
|
||||
request) + `AppEvent::RelationshipsRefreshed` from the runtime's
|
||||
schema refresh.
|
||||
2. **Nav overlay = partial clear + 1-col gutter** (DC2), not a full-area
|
||||
clear — truer to "underneath keeps its layout."
|
||||
|
||||
## §4. Process notes
|
||||
|
||||
- **The pre-build `/runda` pass earned its keep again.** It caught the
|
||||
`Ctrl-B`/tmux collision, a `SchemaCache` retype that would have broken
|
||||
completion, the 2-row-input/indicator placement, the missing nav-mode
|
||||
key disposition + modal gate, and **three unreferenced requirements**
|
||||
(S1 evolved, S2 overridden, S4 corrected — `requirements.md` updated).
|
||||
- **Snapshot discipline:** DB1's 90-col threshold collided with the
|
||||
test-suite's 80-col convention — many snapshots/tests were retuned
|
||||
(sidebar-dependent ones now render at 110; input tests at narrower
|
||||
widths so the now-wider input still overflows). One masked-intent
|
||||
integration check (matched "Customers" in output, not the panel) was
|
||||
corrected.
|
||||
- Each phase was committed green + clippy-clean, user-confirmed message,
|
||||
no AI attribution, append-only.
|
||||
|
||||
## §5. Requirements / S-items touched
|
||||
|
||||
`requirements.md` annotated: **S1** (three-region layout → left region
|
||||
width-optional), **S2** (*overridden* — relationships get a sibling
|
||||
panel, not nested), **S4** (*corrected* — the "keyboard-toggleable" hint
|
||||
claim was never implemented and is struck; the panel is always-on).
|
||||
|
||||
## §6. Remaining open landscape (unchanged from handoff-62, minus the closed items)
|
||||
|
||||
1. **TT5 CI** — test infra solid (2263 green); no pipeline. Gitea Actions
|
||||
/ Woodpecker decision + likely a Linux-first scope call.
|
||||
2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1**
|
||||
app-commands; both net-new, own ADR. SD1 should seed m:n junctions.
|
||||
3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model
|
||||
redesign, design-first, own ADR.
|
||||
4. **C3a modify relationship** — small follow-up (drop+add covers it).
|
||||
5. **#22 overlay/annotation layer** — own ADR; shares the cast + overlay
|
||||
space with DC2 (designed to coexist).
|
||||
6. **Tutorial/lesson system** — acknowledged in scope; needs its own ADR.
|
||||
|
||||
**ADR-0046 OOS (deferred):** true multi-line input (I1); readline
|
||||
shortcuts (I1b); cross-session sidebar persistence; a persistent
|
||||
show/hide toggle (Ctrl-O peek covers it); output as a third nav focus;
|
||||
relationship search/edit from the panel; a hint-area toggle.
|
||||
|
||||
## §7. How to take over
|
||||
|
||||
1. Read handoffs 61 → 62 → 63, then `CLAUDE.md`, `docs/requirements.md`,
|
||||
`docs/adr/README.md`, and **ADR-0046** (fully landed).
|
||||
2. **Pending:** an uncommitted docs finalization (ADR-0046 status →
|
||||
*implemented*; README index status; this handoff). Commit it as
|
||||
`docs: session handoff 63` (the user confirms commit messages).
|
||||
3. **For UI/layout work:** `src/ui.rs` now has `panel_heights`,
|
||||
`sidebar_visible`, `relationships_panel_height`, the nav overlay, and
|
||||
`&mut App` sidebar panels (they report scroll viewports). `App` gained
|
||||
`input_scroll_offset`, `nav_focus`, `relationships`, and the
|
||||
`tables_scroll` / `relationships_scroll` (+ `last_*_visible`) fields.
|
||||
4. **For relationship/schema-cache work:** relationship *names* are in
|
||||
`SchemaCache.relationships` (completion); full records are on
|
||||
`App.relationships` via `Database::read_all_relationships` +
|
||||
`RelationshipsRefreshed`.
|
||||
5. **Eyeball reminder honoured:** the user reviewed the nav overlay
|
||||
appearance and chose the partial-clear + 1-col-gutter variant.
|
||||
6. Run a `cargo sweep` at some point — `target/` has grown across this
|
||||
build-heavy session.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Session handoff — 2026-06-11 (64)
|
||||
|
||||
Sixty-fourth handover. Continues from handoff-63 (ADR-0046 sidebar/nav).
|
||||
This session closed **two unrelated, website-screencast-enabling Gitea
|
||||
issues**: **#24** (vi-style load-picker navigation) and **#22**
|
||||
(in-app demonstration overlay layer — its own **ADR-0047**, built end
|
||||
to end across three phases + a restyle).
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. **HEAD `2d0f4b2`** plus an **uncommitted docs
|
||||
finalization** (ADR-0047 status → implemented, README index, this
|
||||
handoff — see §6). Push is the user's step.
|
||||
|
||||
**Tests: 2290 passing / 0 failing / 0 skipped / 1 ignored** (the 1
|
||||
ignored is the long-standing `friendly` doctest). **Clippy clean**
|
||||
(nursery, all targets). +27 over the handoff-63 baseline of 2263.
|
||||
|
||||
**This session's commits:**
|
||||
```
|
||||
2d0f4b2 feat(ui): flat filled rectangles for demo overlays (#22, ADR-0047 D4)
|
||||
241f60c feat(ui): demo-mode step-caption stealth buffer (#22, ADR-0047 D3/D4)
|
||||
2584e76 feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5)
|
||||
f879d54 feat(cli): --demo demonstration mode flag + app plumbing (#22, ADR-0047 D1)
|
||||
e9eb1b1 docs: ADR-0047 — demonstration overlay layer for casts/teaching (#22)
|
||||
638b4c9 feat(app): vi-style j/k/g/G navigation in the load picker (#24)
|
||||
```
|
||||
|
||||
**Issues closed:** **#24** (vi nav) and **#22** (demo overlays) — close
|
||||
#22 once the docs finalization commit lands.
|
||||
|
||||
## §2. #24 — vi-style load-picker navigation (commit `638b4c9`)
|
||||
|
||||
Purely additive to the ADR-0015 load picker (`handle_load_picker_key`,
|
||||
`LoadPickerSubMode::List`): **`j`/`k`** mirror Down/Up (bounds-
|
||||
respecting, no wrap), **`g`/`G`** jump to first/last. Existing keys
|
||||
(`↑↓`/`Enter`/`Esc`/`b`) unchanged; the footer hint is **left as-is** at
|
||||
the user's request (the new keys are not advertised). No ADR (additive).
|
||||
Motivation: `autocast` (the website cast driver) can only send typeable
|
||||
characters — not arrow keys — so the projects demo needs `j`/`k` to
|
||||
drive the picker. Tests: `load_picker_jk_navigates_like_arrows`,
|
||||
`load_picker_g_jumps_to_first_and_last` (test-first).
|
||||
|
||||
## §3. #22 — ADR-0047 demonstration overlay layer (read the ADR)
|
||||
|
||||
An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO`
|
||||
env, **off by default, zero footprint when off**) that renders two
|
||||
transient overlays so `autocast` screencasts — and live teaching, and a
|
||||
future guided-lesson system — can show otherwise-invisible interactions.
|
||||
|
||||
- **Phase A (`f879d54`):** `--demo` + env → `App.demo_mode`, threaded
|
||||
through `run_loop` like `--no-undo`. `--help` line mentions **only the
|
||||
visible badges**; the `Ctrl+]` caption trigger is kept low-profile
|
||||
(user decision, D6).
|
||||
- **Phase B (`2584e76`):** **automatic keystroke badges**
|
||||
(`[TAB]`/`[ENTER]`/`[UP]`/…) over a fixed set of glyph-less keys —
|
||||
pure `demo_badge_label(&KeyEvent)`, set in `App::update` **before the
|
||||
modal gate** (so they fire over the load picker), expired by a **~1.5 s
|
||||
runtime timer**. The timer extends the event loop's time-boxed-`recv`
|
||||
via a new pure `nearest_deadline` helper; the rewrite tracks `Instant`
|
||||
deadlines and was **verified not to regress the ADR-0027 indicator
|
||||
debounce**. New `App.last_output_area: Rect` (set in
|
||||
`render_output_panel`) anchors the overlays.
|
||||
- **Phase C (`241f60c`):** the **stealth `Ctrl+]` caption buffer** —
|
||||
`Ctrl+]` (byte `0x1D` → `Char('5')+CONTROL`, verified vs crossterm
|
||||
0.29) toggles an invisible buffer; typed chars accumulate without
|
||||
touching input/output, `Backspace` edits, other keys inert, a second
|
||||
`Ctrl+]` commits (empty commit dismisses). In pure-sync `App::update`,
|
||||
intercepted **before the modal gate**; an ordinary keystroke clears a
|
||||
visible caption.
|
||||
- **Restyle (`2d0f4b2`):** the overlays render as **flat filled yellow
|
||||
rectangles** (no border glyphs, one-cell text margin) — user decision,
|
||||
deliberately unlike the bordered panels so they pop. Shared
|
||||
`fill_overlay_rect` (borderless `Block` fill + inset `Paragraph`).
|
||||
|
||||
**Placement:** both float at the output panel's inner bottom-right,
|
||||
drawn **last over modals**, badge **stacked directly above** the caption
|
||||
when both show; caption **wraps to ≤ 3 lines** then ellipsises; clamp/
|
||||
skip guard for tiny terminals.
|
||||
|
||||
**Process:** ADR-first (user chose), pre-build `/runda` (10 findings,
|
||||
folded in) + whole-implementation `/runda` (**PASS, no blockers**). Every
|
||||
fork user-confirmed via mockups/questions, incl. the two post-draft
|
||||
follow-ups: `Ctrl+]` trigger (over `Ctrl+!`, which `autocast` cannot
|
||||
send — not a single ASCII byte) and wrap-to-3-line captions.
|
||||
|
||||
## §4. Two things to know about the implementation
|
||||
|
||||
1. **Ownership split (intentional, mirrors `input`/`input_indicator`):**
|
||||
`demo_caption`/`demo_caption_capturing`/`demo_caption_buffer` are
|
||||
driven by `App::update` (input); `demo_badge` is **set** by
|
||||
`App::update` but its expiry is **timed by the runtime**
|
||||
(`demo_badge_seq` bumps so a repeated key restarts the timer).
|
||||
2. **`Ctrl-C` is inert while capturing** — by spec ("every other key is
|
||||
inert"); exit capture with `Ctrl+]`. User-acknowledged; flagged in
|
||||
the ADR. The only behaviour worth a second look if it ever annoys.
|
||||
|
||||
## §5. Honest coverage note
|
||||
|
||||
Everything *testable* is tested (label fn, full caption FSM incl.
|
||||
over-modal + demo-off, `nearest_deadline`, all rendering, CLI parse/env).
|
||||
The **only** untested wiring is inside `run_loop` (the badge-timer
|
||||
arm/clear and `app.demo_mode = demo_mode`) — `run_loop` is not
|
||||
unit-testable (terminal + DB + channels), exactly the posture the
|
||||
existing `IndicatorDebounce` already takes. A future Tier-4 PTY harness
|
||||
(ADR-0008 TT4, still unwired) would close it.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 62 → 63 → 64, `CLAUDE.md`, `docs/requirements.md`,
|
||||
`docs/adr/README.md`, and **ADR-0047** (fully landed).
|
||||
2. **Pending:** the docs finalization commit (ADR-0047 status →
|
||||
implemented; README index; this handoff). Commit as
|
||||
`docs: session handoff 64 + ADR-0047 implemented (#22/#24)` (the user
|
||||
confirms commit messages). Then close **#22** on Gitea.
|
||||
3. **For demo-overlay work:** `App` has `demo_mode`, `demo_badge`,
|
||||
`demo_badge_seq`, `demo_caption`, `demo_caption_capturing`,
|
||||
`demo_caption_buffer`, `last_output_area`. Rendering:
|
||||
`render_demo_overlays` / `render_badge_box` / `render_caption_box` /
|
||||
`fill_overlay_rect` in `ui.rs`; colours `DEMO_OVERLAY_FG/BG` in
|
||||
`theme.rs`; key handling `handle_demo_caption_key` + the top-of-
|
||||
`handle_key` gate; timer in `runtime.rs` (`nearest_deadline`,
|
||||
`DEMO_BADGE_TTL`).
|
||||
|
||||
## §7. Remaining open landscape (from handoff-63, minus the closed items)
|
||||
|
||||
1. **Wire the overlays into the website casts** — `casts.mjs` on the
|
||||
`website` branch can now emit `^]`/text/`^]` for captions and rely on
|
||||
automatic badges. Website-branch follow-up (OOS for #22's app scope).
|
||||
2. **TT5 CI** — 2290 green, no pipeline yet.
|
||||
3. **SD1 `seed`** then **H2 `hint`** — the unblockers for **A1**
|
||||
app-commands; own ADRs.
|
||||
4. **V2/S3 multi-result tabs** / **V4 journal** — larger output-model
|
||||
redesign, own ADR.
|
||||
5. **C3a modify relationship** — small (drop+add covers it).
|
||||
6. **Tutorial/lesson system** — acknowledged in scope; needs its own
|
||||
ADR; ADR-0047's overlay primitive is what it will reuse.
|
||||
|
||||
Run a `cargo sweep` at some point — `target/` grew across this
|
||||
build-heavy session.
|
||||
+53
-15
@@ -73,24 +73,38 @@ since ADR-0027.)
|
||||
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.)*
|
||||
panel / input field / hint panel; rendered every frame.
|
||||
**ADR-0046 evolves this:** the left items region becomes
|
||||
width-optional — hidden by default at ≤ 90 columns, peek-revealed
|
||||
via `Ctrl-O` navigation mode — so the three-region layout is the
|
||||
wide-terminal default, not an invariant.)*
|
||||
- [x] **S2** Items list shows tables and per-table indexes;
|
||||
designed to extend to additional element kinds (relations,
|
||||
views, etc.) without restructuring.
|
||||
*(ADR-0025: the items panel renders a nested list — each
|
||||
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.
|
||||
**ADR-0046 overrides the nesting approach for relationships:**
|
||||
because relationships are cross-table rather than per-table, they
|
||||
get their own sibling panel stacked below the tables list, not
|
||||
nested items within it — user-confirmed 2026-06-10.)*
|
||||
- [/] **S3** Output panel renders a visualization of the
|
||||
currently selected item and supports multiple tabs.
|
||||
*(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.
|
||||
- [x] **S4** Hint area below the input field, showing hints about
|
||||
the current input or last error.
|
||||
*(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.)*
|
||||
the input showing ambient hints, candidates, or the last error.
|
||||
**Correction (2026-06-10, ADR-0046):** the original wording said
|
||||
the area was "keyboard-toggleable"; that was never implemented and
|
||||
is deliberately dropped — the panel became indispensable once
|
||||
completion moved into it (ADR-0022), so it is always on. ADR-0046
|
||||
replaces its content-driven height with a geometry-driven one to
|
||||
stop the resize jump (#20); no toggle is added.)*
|
||||
- [x] **S5** Mode label and distinct border style on the input
|
||||
field communicate the current input mode at all times.
|
||||
*(Verified 2026-06-07: `ui.rs:896-934` `render_input_panel` —
|
||||
@@ -276,9 +290,23 @@ since ADR-0027.)
|
||||
the same via drop + add today; one-step modify is a small
|
||||
follow-up using the existing rebuild-table machinery. ADR
|
||||
pending.
|
||||
- [ ] **C4** Convenience: `create m:n relationship from <T1> to
|
||||
- [x] **C4** Convenience: `create m:n relationship from <T1> to
|
||||
<T2>` produces an auto-named junction table the user can rename;
|
||||
pulls primary keys and FK definitions automatically.
|
||||
*(Done 2026-06-10 via **ADR-0045**. `create m:n relationship from
|
||||
<T1> to <T2> [as <name>]` builds a junction table with one FK column
|
||||
per parent PK column (`{table}_{pkcol}`, typed via `fk_target_type`),
|
||||
a **compound PK** over them, and two **`CASCADE`** 1:n relationships
|
||||
— all in one `do_create_table` call = one undo step. Auto-named
|
||||
`{T1}_{T2}` (optional `as`), available in both modes, compound-parent
|
||||
PKs supported (ADR-0043). Self-referential m:n refused; PK-less parent
|
||||
refused. Wired across every surface — completion (`m:n` composite),
|
||||
hints, highlighting, `help`/usage, and the advanced-mode DSL→SQL
|
||||
teaching echo (the generated `CREATE TABLE … FOREIGN KEY …`). 9
|
||||
integration + 7 typing-surface + echo/parse unit tests. The build
|
||||
surfaced — and fixed — two latent simple-mode dispatch/completion
|
||||
assumptions ("≤1 DSL form per entry word"), now generalized
|
||||
behaviour-preservingly.)*
|
||||
- [x] **C5** Data operations: insert / update / delete via DSL.
|
||||
*(ADR-0014. INSERT short and long forms, UPDATE/DELETE with
|
||||
required WHERE plus `--all-rows` opt-in, `show data <T>`,
|
||||
@@ -804,17 +832,27 @@ since ADR-0027.)
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
- [/] **X1** Comprehensive logging via the project's logging
|
||||
- [x] **X1** Comprehensive logging via the project's logging
|
||||
infrastructure per `CLAUDE.md` (decision points, parameter
|
||||
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.)*
|
||||
*(Done 2026-06-10 via a full-sweep instrumentation pass. The
|
||||
prior state (verified 2026-06-07) was a wired harness
|
||||
(`src/logging.rs`) but sparse instrumentation — failure-path
|
||||
heavy, nothing in `db.rs`/parser/executors. The sweep brought
|
||||
every layer to the "log liberally" bar under a documented level
|
||||
discipline (see the `logging.rs` module doc): **`db.rs`** gained
|
||||
entry-level `debug!` on all 34 `do_*` executors plus decision-point
|
||||
logs (rebuild-table primitive, insert auto-fill, delete cascade,
|
||||
FK resolution) — so the route through delegating executors is
|
||||
visible in the log sequence; **persistence** logs every
|
||||
yaml/CSV/history write (the silent-failure paths); **runtime**
|
||||
logs `execute_command_typed` dispatch; **`app.rs`** logs
|
||||
submit / app-command dispatch / render-mode choice; the **parser**
|
||||
logs parse begin/outcome at `trace` (it is a per-keystroke hot
|
||||
path). Levels: `debug` for per-command detail (off by default,
|
||||
`RDBMS_PLAYGROUND_LOG=debug`), `info` for lifecycle, `warn` for
|
||||
fallbacks, `trace` for hot paths. Emission verified end-to-end
|
||||
through the real worker thread + `logging::init`. ~75 → ~135 sites.)*
|
||||
- [~] **X2** Language: English-only for v1; multi-language is an
|
||||
open question to revisit later.
|
||||
- [~] **X3** Accessibility: TUI screen-reader support is
|
||||
|
||||
@@ -41,6 +41,22 @@ entry names the ADR that drew the boundary.
|
||||
|
||||
## Table creation (ADR-0029)
|
||||
|
||||
- **A simple-mode table always has a primary key; an advanced-mode
|
||||
table need not.** `create table … with pk …` is mandatory in simple
|
||||
mode (ADR-0029) — the bare `with pk` even defaults to `id serial`.
|
||||
Advanced-mode SQL follows standard SQL and permits a *PK-less* table:
|
||||
`create table t (a int)` declares no primary key. This is **not** a
|
||||
storage problem — every ordinary table (STRICT included) carries
|
||||
SQLite's implicit `rowid`, which keys it internally; only a
|
||||
`WITHOUT ROWID` table (which this app never creates) would lack one.
|
||||
So the simple-mode requirement is a *pedagogical* boundary (teach that
|
||||
tables should have a key), not an engine constraint. Consequences in a
|
||||
PK-less table, all handled: `show data … limit` falls back to rowid
|
||||
order (no stable user-facing key to order by); `update` / `delete`
|
||||
still target the affected rows by rowid; and there is no "PK column"
|
||||
to drop — dropping a *declared* PK column is refused in **both** modes
|
||||
(the shared `do_drop_column` guard: *"cannot drop primary-key column
|
||||
…"*).
|
||||
- **`create table` declares only primary-key columns.**
|
||||
`create table T with pk …` makes every listed column part
|
||||
of the primary key; there is no simple-mode syntax for a
|
||||
|
||||
+650
-12
@@ -9,6 +9,7 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::layout::Rect;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::action::Action;
|
||||
@@ -226,6 +227,28 @@ impl EffectiveMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation-mode focus cursor (ADR-0046 DC1).
|
||||
///
|
||||
/// `Input` means not in navigation mode — keystrokes edit the command
|
||||
/// input as usual. `Ctrl-O` cycles Input → SidebarTables →
|
||||
/// SidebarRelationships → Input; while a sidebar panel is focused the
|
||||
/// sidebar is revealed (peek) and expanded as an overlay, and scroll
|
||||
/// keys drive it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum NavFocus {
|
||||
#[default]
|
||||
Input,
|
||||
SidebarTables,
|
||||
SidebarRelationships,
|
||||
}
|
||||
|
||||
impl NavFocus {
|
||||
/// True while a sidebar panel is focused (navigation mode is active).
|
||||
pub const fn in_sidebar(self) -> bool {
|
||||
matches!(self, Self::SidebarTables | Self::SidebarRelationships)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct App {
|
||||
pub mode: Mode,
|
||||
@@ -237,6 +260,15 @@ pub struct App {
|
||||
/// Byte offset into `input` where the next character will be
|
||||
/// inserted. Always lies on a UTF-8 character boundary.
|
||||
pub input_cursor: usize,
|
||||
/// First visible display column of the input line when it is too
|
||||
/// long to fit the input panel (ADR-0046 DA3). The renderer keeps
|
||||
/// the cursor in view by adjusting this; it resets to 0 whenever the
|
||||
/// buffer is replaced wholesale (submit / history navigation).
|
||||
pub input_scroll_offset: usize,
|
||||
/// Navigation-mode focus cursor (ADR-0046 DC1). `Input` when not in
|
||||
/// navigation mode. Driven by `Ctrl-O` / `Esc`; the renderer reveals
|
||||
/// + expands the focused sidebar panel as an overlay.
|
||||
pub nav_focus: NavFocus,
|
||||
pub output: VecDeque<OutputLine>,
|
||||
pub hint: Option<String>,
|
||||
/// The validity indicator's currently-visible verdict
|
||||
@@ -247,6 +279,12 @@ pub struct App {
|
||||
/// [`App::input_validity_verdict`] once typing pauses.
|
||||
pub input_indicator: Option<crate::dsl::walker::Severity>,
|
||||
pub tables: Vec<String>,
|
||||
/// All relationships as full schema records, for the sidebar
|
||||
/// relationships panel (ADR-0046 DB2). Refreshed by the runtime
|
||||
/// alongside `tables`. Kept on the App (not `SchemaCache`) because
|
||||
/// only the UI needs the details — the walker/completion need just
|
||||
/// the names, which stay in `SchemaCache::relationships`.
|
||||
pub relationships: Vec<crate::persistence::RelationshipSchema>,
|
||||
/// Last successfully described table, shown in the output
|
||||
/// pane until the next DDL operation.
|
||||
pub current_table: Option<TableDescription>,
|
||||
@@ -286,6 +324,20 @@ pub struct App {
|
||||
/// diagram's side-by-side vs vertical layout choice. Defaults to
|
||||
/// `80` until the first render measures the real width.
|
||||
pub last_output_width: u16,
|
||||
/// The most recent **inner area** (inside the border) of the output
|
||||
/// panel, recorded by the renderer (ADR-0047 D4). The demo overlays
|
||||
/// anchor to its bottom-right corner; read at the top-level draw
|
||||
/// pass, which otherwise does not know where the output panel sits.
|
||||
/// Zero-sized until the first render measures it.
|
||||
pub last_output_area: Rect,
|
||||
/// Top visible row of the Tables / Relationships sidebar panels
|
||||
/// while scrolled in navigation mode (ADR-0046 DC3), with the most
|
||||
/// recent visible-row count the renderer reported for each (used to
|
||||
/// page-scroll and to clamp the offset). `0` = showing from the top.
|
||||
pub tables_scroll: usize,
|
||||
pub relationships_scroll: usize,
|
||||
pub last_tables_visible: usize,
|
||||
pub last_relationships_visible: usize,
|
||||
/// Prettified display name of the currently-open project,
|
||||
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
||||
/// during very-early startup before the runtime has opened a
|
||||
@@ -323,6 +375,38 @@ pub struct App {
|
||||
/// flag; the `undo` / `redo` commands then report undo is off
|
||||
/// rather than emitting a prepare action.
|
||||
pub undo_enabled: bool,
|
||||
/// Whether **demonstration mode** is active this session (ADR-0047,
|
||||
/// issue #22). `true` under `--demo` / `RDBMS_PLAYGROUND_DEMO`. When
|
||||
/// off (the default) none of the demo key handling or overlay
|
||||
/// rendering runs — zero footprint. When on, otherwise-invisible
|
||||
/// keys raise a transient badge (`demo_badge`) and `Ctrl+]` drives
|
||||
/// the stealth step-caption buffer (`demo_caption` / `demo_capturing`,
|
||||
/// Phase C).
|
||||
pub demo_mode: bool,
|
||||
/// The keystroke badge currently displayed in demo mode (ADR-0047
|
||||
/// D2), e.g. `"[TAB]"`. Set in `update()` when an otherwise-invisible
|
||||
/// key is handled; cleared by the runtime when its ~1.5 s timer
|
||||
/// elapses (the timing lives in the runtime, mirroring how
|
||||
/// `input_indicator` is driven from `IndicatorDebounce`). `None` when
|
||||
/// no badge is showing.
|
||||
pub demo_badge: Option<&'static str>,
|
||||
/// Monotonic counter bumped every time `demo_badge` is (re)set
|
||||
/// (ADR-0047 D5). The runtime watches it so a *new* badge — even the
|
||||
/// same label twice in a row (Tab, Tab) — restarts the expiry timer.
|
||||
pub demo_badge_seq: u64,
|
||||
/// The step-caption currently displayed in demo mode (ADR-0047 D3),
|
||||
/// or `None`. Committed from the stealth buffer on the closing
|
||||
/// `Ctrl+]`; cleared by the next ordinary keystroke (or an empty
|
||||
/// commit). Rendered as a wrapped box stacked above the badge.
|
||||
pub demo_caption: Option<String>,
|
||||
/// Whether the stealth caption buffer is open (ADR-0047 D3): between
|
||||
/// the opening and closing `Ctrl+]`, typed characters accumulate into
|
||||
/// `demo_caption_buffer` invisibly and every other key is inert.
|
||||
pub demo_caption_capturing: bool,
|
||||
/// The invisible accumulator for the caption being typed while
|
||||
/// `demo_caption_capturing` (ADR-0047 D3). Never rendered directly;
|
||||
/// its trimmed contents become `demo_caption` on commit.
|
||||
pub demo_caption_buffer: String,
|
||||
/// The DSL → SQL teaching echo (ADR-0038) for the command currently
|
||||
/// being rendered: set from the success event just before its handler
|
||||
/// runs, consumed by `note_ok_summary` (which pushes it beneath
|
||||
@@ -425,6 +509,36 @@ const PAGE_SCROLL_LINES: usize = 5;
|
||||
|
||||
const HISTORY_CAPACITY: usize = 1000;
|
||||
|
||||
/// The demo-mode keystroke badge for `key`, or `None` if the key
|
||||
/// produces a glyph of its own (and so needs no badge) — ADR-0047 D2.
|
||||
///
|
||||
/// The set is exactly the *otherwise-invisible* keys: motion, editing,
|
||||
/// submission, and the `Ctrl-O` navigation toggle. Plain character keys
|
||||
/// already appear on the input line, and `Ctrl-C` (quit) / `Ctrl+]`
|
||||
/// (the caption toggle) are deliberately excluded. Pure and total, so
|
||||
/// it is exhaustively unit-testable without a running app.
|
||||
pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Tab, _) => Some("[TAB]"),
|
||||
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
|
||||
(KeyCode::Enter, _) => Some("[ENTER]"),
|
||||
(KeyCode::Esc, _) => Some("[ESC]"),
|
||||
(KeyCode::Up, _) => Some("[UP]"),
|
||||
(KeyCode::Down, _) => Some("[DOWN]"),
|
||||
(KeyCode::Left, _) => Some("[LEFT]"),
|
||||
(KeyCode::Right, _) => Some("[RIGHT]"),
|
||||
(KeyCode::Home, _) => Some("[HOME]"),
|
||||
(KeyCode::End, _) => Some("[END]"),
|
||||
(KeyCode::PageUp, _) => Some("[PGUP]"),
|
||||
(KeyCode::PageDown, _) => Some("[PGDN]"),
|
||||
(KeyCode::Backspace, _) => Some("[BKSP]"),
|
||||
(KeyCode::Delete, _) => Some("[DEL]"),
|
||||
// The only badged control chord: the ADR-0046 navigation toggle.
|
||||
(KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => Some("[CTRL-O]"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -439,10 +553,13 @@ impl App {
|
||||
messages_verbosity: crate::friendly::Verbosity::default(),
|
||||
input: String::new(),
|
||||
input_cursor: 0,
|
||||
input_scroll_offset: 0,
|
||||
nav_focus: NavFocus::Input,
|
||||
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
||||
hint: None,
|
||||
input_indicator: None,
|
||||
tables: Vec::new(),
|
||||
relationships: Vec::new(),
|
||||
current_table: None,
|
||||
history: Vec::new(),
|
||||
history_cursor: None,
|
||||
@@ -451,6 +568,11 @@ impl App {
|
||||
last_output_visible: 0,
|
||||
last_output_total_wrapped: 0,
|
||||
last_output_width: 80,
|
||||
last_output_area: Rect::new(0, 0, 0, 0),
|
||||
tables_scroll: 0,
|
||||
relationships_scroll: 0,
|
||||
last_tables_visible: 0,
|
||||
last_relationships_visible: 0,
|
||||
project_name: None,
|
||||
project_is_temp: false,
|
||||
fatal_message: None,
|
||||
@@ -460,6 +582,14 @@ impl App {
|
||||
// Undo is on by default; the runtime flips this off for
|
||||
// a `--no-undo` session (ADR-0006 Amendment 1).
|
||||
undo_enabled: true,
|
||||
// Demo mode is off by default; the runtime flips it on for
|
||||
// a `--demo` session (ADR-0047).
|
||||
demo_mode: false,
|
||||
demo_badge: None,
|
||||
demo_badge_seq: 0,
|
||||
demo_caption: None,
|
||||
demo_caption_capturing: false,
|
||||
demo_caption_buffer: String::new(),
|
||||
pending_echo: None,
|
||||
}
|
||||
}
|
||||
@@ -715,6 +845,11 @@ impl App {
|
||||
self.schema_cache = cache;
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::RelationshipsRefreshed(relationships) => {
|
||||
trace!(count = relationships.len(), "relationships refreshed");
|
||||
self.relationships = relationships;
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::PersistenceFatal {
|
||||
operation,
|
||||
path,
|
||||
@@ -904,6 +1039,64 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-0046 DC1: advance the navigation focus cycle. From `Input`
|
||||
/// it enters navigation mode on the Tables panel (revealing +
|
||||
/// expanding the sidebar via the renderer); the third press returns
|
||||
/// to the command input.
|
||||
fn nav_advance(&mut self) {
|
||||
self.nav_focus = match self.nav_focus {
|
||||
NavFocus::Input => NavFocus::SidebarTables,
|
||||
NavFocus::SidebarTables => NavFocus::SidebarRelationships,
|
||||
NavFocus::SidebarRelationships => NavFocus::Input,
|
||||
};
|
||||
trace!(nav_focus = ?self.nav_focus, "navigation focus advanced");
|
||||
}
|
||||
|
||||
/// Leave navigation mode, returning focus to the command input
|
||||
/// (ADR-0046 DC1 — the `Esc` shortcut for the cycle's last step).
|
||||
const fn nav_exit(&mut self) {
|
||||
self.nav_focus = NavFocus::Input;
|
||||
}
|
||||
|
||||
/// ADR-0046 DC3/DC4: key handling while a sidebar panel is focused.
|
||||
/// `Esc` exits navigation mode; scroll keys drive the focused panel
|
||||
/// (wired in DC3); every other key is inert because the command
|
||||
/// input is occluded by the expanded sidebar overlay.
|
||||
fn handle_nav_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||
match key.code {
|
||||
KeyCode::Esc => self.nav_exit(),
|
||||
KeyCode::Up => self.nav_scroll(-1),
|
||||
KeyCode::Down => self.nav_scroll(1),
|
||||
KeyCode::PageUp => self.nav_scroll_page(-1),
|
||||
KeyCode::PageDown => self.nav_scroll_page(1),
|
||||
_ => {}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Scroll the focused sidebar panel by `lines` (ADR-0046 DC3); the
|
||||
/// renderer clamps the offset to the panel's content on the next
|
||||
/// frame, so over-scrolling is harmless.
|
||||
const fn nav_scroll(&mut self, lines: i32) {
|
||||
let slot = match self.nav_focus {
|
||||
NavFocus::SidebarTables => &mut self.tables_scroll,
|
||||
NavFocus::SidebarRelationships => &mut self.relationships_scroll,
|
||||
NavFocus::Input => return,
|
||||
};
|
||||
*slot = slot.saturating_add_signed(lines as isize);
|
||||
}
|
||||
|
||||
/// Page-scroll the focused panel by its last reported visible-row
|
||||
/// count (ADR-0046 DC3).
|
||||
fn nav_scroll_page(&mut self, dir: i32) {
|
||||
let visible = match self.nav_focus {
|
||||
NavFocus::SidebarTables => self.last_tables_visible,
|
||||
NavFocus::SidebarRelationships => self.last_relationships_visible,
|
||||
NavFocus::Input => return,
|
||||
};
|
||||
self.nav_scroll(dir * (visible.max(1) as i32));
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||
// On Windows, key events fire for both Press and Release;
|
||||
// honour only Press to avoid double-handling. Other
|
||||
@@ -913,6 +1106,32 @@ impl App {
|
||||
}
|
||||
trace!(?key, "handle_key");
|
||||
|
||||
// ADR-0047 D3: the demo step-caption stealth buffer runs before
|
||||
// every other gate — even ahead of the badge and the modal gate —
|
||||
// so it can be authored over the load picker (the `#24` cast) and
|
||||
// so captured keystrokes never leak into the input, a badge, or a
|
||||
// command. `Ctrl+]` toggles capture; while capturing, the key is
|
||||
// consumed here.
|
||||
if self.demo_mode {
|
||||
if let Some(actions) = self.handle_demo_caption_key(key) {
|
||||
return actions;
|
||||
}
|
||||
// Not a caption key: any ordinary keystroke dismisses a
|
||||
// visible caption (it then falls through to normal handling).
|
||||
self.demo_caption = None;
|
||||
}
|
||||
|
||||
// ADR-0047 D2: in demo mode raise a transient badge for an
|
||||
// otherwise-invisible key. Done before the modal / nav gates so
|
||||
// it fires even while a modal is open (the `#24` projects cast)
|
||||
// or in navigation mode. The runtime times its expiry (D5).
|
||||
if self.demo_mode
|
||||
&& let Some(label) = demo_badge_label(&key)
|
||||
{
|
||||
self.demo_badge = Some(label);
|
||||
self.demo_badge_seq = self.demo_badge_seq.wrapping_add(1);
|
||||
}
|
||||
|
||||
// While a modal is open it owns the keyboard. Normal
|
||||
// input editing, history navigation, and command
|
||||
// submission are all gated behind closing the modal.
|
||||
@@ -920,6 +1139,20 @@ impl App {
|
||||
return self.handle_modal_key(key);
|
||||
}
|
||||
|
||||
// ADR-0046 DC1: `Ctrl-O` cycles navigation focus from any state
|
||||
// (Input → Tables → Relationships → Input), inert only behind a
|
||||
// modal (handled above).
|
||||
if (key.code, key.modifiers) == (KeyCode::Char('o'), KeyModifiers::CONTROL) {
|
||||
self.nav_advance();
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// DC3/DC4: in navigation mode, keys drive the focused sidebar
|
||||
// panel (scroll) or are inert; the command input is occluded.
|
||||
if self.nav_focus.in_sidebar() {
|
||||
return self.handle_nav_key(key);
|
||||
}
|
||||
|
||||
// ADR-0022 stage 8 — non-modal completion. Tab /
|
||||
// Shift-Tab cycle; Esc / Backspace undo the whole
|
||||
// last-Tab insertion in one keystroke while the memo
|
||||
@@ -1002,6 +1235,59 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the demo step-caption stealth buffer (ADR-0047 D3).
|
||||
///
|
||||
/// Returns `Some(_)` when the key belongs to the caption mechanism
|
||||
/// (the `Ctrl+]` toggle, or any key while capturing) — the caller
|
||||
/// then returns it and processes nothing else. Returns `None` when
|
||||
/// the key is not consumed, so normal handling continues.
|
||||
///
|
||||
/// `Ctrl+]` decodes to `Char('5') + CONTROL` (ADR-0047 D3, verified
|
||||
/// against crossterm 0.29). Only active in demo mode (the caller
|
||||
/// gates on `self.demo_mode`).
|
||||
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
|
||||
let is_toggle = key.code == KeyCode::Char('5')
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
if self.demo_caption_capturing {
|
||||
if is_toggle {
|
||||
// Commit: a trimmed, non-empty buffer becomes the caption;
|
||||
// an empty commit dismisses any caption (explicit clear).
|
||||
self.demo_caption_capturing = false;
|
||||
let text = std::mem::take(&mut self.demo_caption_buffer);
|
||||
let trimmed = text.trim();
|
||||
self.demo_caption =
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string());
|
||||
} else {
|
||||
match key.code {
|
||||
// Plain characters accumulate invisibly; the prompt
|
||||
// and output are untouched.
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
self.demo_caption_buffer.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.demo_caption_buffer.pop();
|
||||
}
|
||||
// Every other key (Enter, arrows, Tab, …) is inert
|
||||
// while capturing.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
if is_toggle {
|
||||
// Open capture. Starting a new annotation clears any caption
|
||||
// currently on screen.
|
||||
self.demo_caption_capturing = true;
|
||||
self.demo_caption_buffer.clear();
|
||||
self.demo_caption = None;
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn cursor_left(&mut self) {
|
||||
let mut idx = self.input_cursor;
|
||||
while idx > 0 {
|
||||
@@ -1232,6 +1518,7 @@ impl App {
|
||||
self.history_cursor = Some(next_index);
|
||||
self.input = self.history[next_index].clone();
|
||||
self.input_cursor = self.input.len();
|
||||
self.input_scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Move forwards in history (towards newer entries; eventually
|
||||
@@ -1250,6 +1537,7 @@ impl App {
|
||||
self.input = self.history_draft.take().unwrap_or_default();
|
||||
}
|
||||
self.input_cursor = self.input.len();
|
||||
self.input_scroll_offset = 0;
|
||||
}
|
||||
|
||||
fn cancel_history_navigation(&mut self) {
|
||||
@@ -1284,6 +1572,7 @@ impl App {
|
||||
fn submit(&mut self) -> Vec<Action> {
|
||||
let raw = std::mem::take(&mut self.input);
|
||||
self.input_cursor = 0;
|
||||
self.input_scroll_offset = 0;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -1311,6 +1600,13 @@ impl App {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
debug!(
|
||||
persistent_mode = ?self.mode,
|
||||
submission_mode = ?submission_mode,
|
||||
len = effective_input.len(),
|
||||
"submit"
|
||||
);
|
||||
|
||||
// Parse-first: app-level commands and DSL commands now
|
||||
// share the chumsky parser (per the round-5 refactor).
|
||||
// App commands work in both modes — they're not gated by
|
||||
@@ -1342,6 +1638,7 @@ impl App {
|
||||
source: &str,
|
||||
) -> Vec<Action> {
|
||||
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
|
||||
debug!(command = ?cmd, "dispatch app command");
|
||||
match cmd {
|
||||
AppCommand::Quit => vec![Action::Quit],
|
||||
AppCommand::Help { topic } => {
|
||||
@@ -1700,6 +1997,7 @@ impl App {
|
||||
| Command::AddRelationship { .. }
|
||||
| Command::DropRelationship { .. }
|
||||
) {
|
||||
debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)");
|
||||
for line in crate::output_render::render_structure_with_diagrams(
|
||||
desc,
|
||||
self.last_output_width,
|
||||
@@ -2049,6 +2347,10 @@ impl App {
|
||||
// column for a compound FK (ADR-0043).
|
||||
parent_columns.first().map(String::as_str),
|
||||
),
|
||||
// m:n builds a junction table; its errors (missing parent,
|
||||
// no PK, self-reference, name collision) name the relevant
|
||||
// table in the message, so no fallback table/column here.
|
||||
C::CreateM2nRelationship { .. } => (Operation::CreateTable, None, None),
|
||||
C::DropRelationship { selector } => match selector {
|
||||
RelationshipSelector::Endpoints {
|
||||
parent_table,
|
||||
@@ -2343,20 +2645,34 @@ impl App {
|
||||
self.note_system(crate::t!("modal.load_cancelled"));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// `k` mirrors Up; vi-style keys keep the picker drivable by
|
||||
// autocast, which can only emit typeable characters (#24).
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if state.selected > 0 {
|
||||
state.selected -= 1;
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// `j` mirrors Down (see the Up arm above).
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if state.selected + 1 < state.entries.len() {
|
||||
state.selected += 1;
|
||||
}
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
// `g` jumps to the first entry, `G` to the last (vi convention).
|
||||
KeyCode::Char('g') => {
|
||||
state.selected = 0;
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Char('G') => {
|
||||
state.selected = state.entries.len().saturating_sub(1);
|
||||
self.modal = Some(Modal::LoadPicker(state));
|
||||
Vec::new()
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(entry) = state.entries.get(state.selected).cloned() {
|
||||
self.modal = None;
|
||||
@@ -2764,6 +3080,209 @@ mod tests {
|
||||
AppEvent::Key(KeyEvent::new(code, mods))
|
||||
}
|
||||
|
||||
// ---- ADR-0047 (issue #22): demo-mode keystroke badges ----
|
||||
|
||||
fn ke(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent::new(code, mods)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_badge_label_maps_the_invisible_keys() {
|
||||
let none = KeyModifiers::NONE;
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Down, none)), Some("[DOWN]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Left, none)), Some("[LEFT]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Right, none)), Some("[RIGHT]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
|
||||
assert_eq!(
|
||||
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
|
||||
Some("[CTRL-O]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
|
||||
// Plain characters render their own glyph — no badge.
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
|
||||
// Quit and the (Phase C) caption toggle are deliberately excluded.
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
|
||||
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
|
||||
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_mode_off_never_sets_a_badge() {
|
||||
let mut app = App::new();
|
||||
assert!(!app.demo_mode);
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.demo_badge, None);
|
||||
assert_eq!(app.demo_badge_seq, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_mode_on_sets_badge_and_bumps_seq() {
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.demo_badge, Some("[TAB]"));
|
||||
assert_eq!(app.demo_badge_seq, 1);
|
||||
|
||||
app.update(key(KeyCode::Enter));
|
||||
assert_eq!(app.demo_badge, Some("[ENTER]"));
|
||||
assert_eq!(app.demo_badge_seq, 2);
|
||||
|
||||
// The same label twice still bumps the seq so the runtime
|
||||
// restarts the expiry timer.
|
||||
app.update(key(KeyCode::Enter));
|
||||
assert_eq!(app.demo_badge, Some("[ENTER]"));
|
||||
assert_eq!(app.demo_badge_seq, 3);
|
||||
|
||||
// A glyph key leaves the badge (and seq) untouched — the
|
||||
// runtime's timer is what clears it, not the next key.
|
||||
app.update(key(KeyCode::Char('x')));
|
||||
assert_eq!(app.demo_badge, Some("[ENTER]"));
|
||||
assert_eq!(app.demo_badge_seq, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_badge_fires_over_an_open_modal() {
|
||||
// Badges are set before the modal gate, so the `#24` projects
|
||||
// cast can show [ENTER]/[DOWN] while the load picker is up.
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
|
||||
entries: Vec::new(),
|
||||
selected: 0,
|
||||
sub_mode: LoadPickerSubMode::List,
|
||||
}));
|
||||
app.update(key(KeyCode::Down));
|
||||
assert_eq!(app.demo_badge, Some("[DOWN]"));
|
||||
assert_eq!(app.demo_badge_seq, 1);
|
||||
}
|
||||
|
||||
// ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ----
|
||||
|
||||
/// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL).
|
||||
fn caption_toggle() -> AppEvent {
|
||||
key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_caption_toggle_captures_then_commits() {
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
|
||||
app.update(caption_toggle());
|
||||
assert!(app.demo_caption_capturing, "first Ctrl+] opens capture");
|
||||
assert_eq!(app.demo_caption, None);
|
||||
|
||||
type_str(&mut app, "Press Tab");
|
||||
// The text accumulates invisibly — nothing on the input line.
|
||||
assert_eq!(app.input, "");
|
||||
assert_eq!(app.demo_caption_buffer, "Press Tab");
|
||||
assert_eq!(app.demo_caption, None, "not shown until committed");
|
||||
|
||||
app.update(caption_toggle());
|
||||
assert!(!app.demo_caption_capturing, "second Ctrl+] commits");
|
||||
assert_eq!(app.demo_caption.as_deref(), Some("Press Tab"));
|
||||
assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit");
|
||||
assert_eq!(app.input, "", "input never touched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_caption_backspace_edits_the_buffer() {
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
app.update(caption_toggle());
|
||||
type_str(&mut app, "Helloo");
|
||||
app.update(key(KeyCode::Backspace));
|
||||
assert_eq!(app.demo_caption_buffer, "Hello");
|
||||
assert_eq!(app.input, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_caption_other_keys_are_inert_while_capturing() {
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
app.update(caption_toggle());
|
||||
type_str(&mut app, "note");
|
||||
// Enter must not submit, Tab must not complete, arrows do nothing.
|
||||
let a1 = app.update(key(KeyCode::Enter));
|
||||
let a2 = app.update(key(KeyCode::Tab));
|
||||
let a3 = app.update(key(KeyCode::Up));
|
||||
assert!(a1.is_empty() && a2.is_empty() && a3.is_empty());
|
||||
assert!(app.demo_caption_capturing, "still capturing");
|
||||
assert_eq!(app.demo_caption_buffer, "note");
|
||||
assert_eq!(app.input, "");
|
||||
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_caption_empty_commit_dismisses() {
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
app.demo_caption = Some("old".to_string());
|
||||
// Open (clears the visible caption) then commit empty.
|
||||
app.update(caption_toggle());
|
||||
assert_eq!(app.demo_caption, None, "opening clears the visible caption");
|
||||
app.update(caption_toggle());
|
||||
assert_eq!(app.demo_caption, None, "empty commit leaves nothing");
|
||||
assert!(!app.demo_caption_capturing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_caption_cleared_by_next_ordinary_keystroke() {
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
app.demo_caption = Some("step 1".to_string());
|
||||
// An ordinary key clears the caption, then is processed normally.
|
||||
app.update(key(KeyCode::Char('a')));
|
||||
assert_eq!(app.demo_caption, None);
|
||||
assert_eq!(app.input, "a", "the key still reaches the input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_caption_captures_over_an_open_modal() {
|
||||
// The stealth buffer sits before the modal gate, so captions can
|
||||
// be authored while the load picker is up (the `#24` cast).
|
||||
let mut app = App::new();
|
||||
app.demo_mode = true;
|
||||
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
|
||||
entries: Vec::new(),
|
||||
selected: 0,
|
||||
sub_mode: LoadPickerSubMode::List,
|
||||
}));
|
||||
app.update(caption_toggle());
|
||||
type_str(&mut app, "pick one");
|
||||
app.update(caption_toggle());
|
||||
assert_eq!(app.demo_caption.as_deref(), Some("pick one"));
|
||||
// The modal is untouched by the capture.
|
||||
assert!(matches!(app.modal, Some(Modal::LoadPicker(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_mode_off_makes_ctrl_rbracket_inert() {
|
||||
let mut app = App::new();
|
||||
assert!(!app.demo_mode);
|
||||
app.update(caption_toggle());
|
||||
type_str(&mut app, "x");
|
||||
assert!(!app.demo_caption_capturing);
|
||||
assert_eq!(app.demo_caption, None);
|
||||
// Ctrl+] did nothing; the later 'x' is an ordinary character.
|
||||
assert_eq!(app.input, "x");
|
||||
}
|
||||
|
||||
fn type_str(app: &mut App, s: &str) {
|
||||
for c in s.chars() {
|
||||
app.update(key(KeyCode::Char(c)));
|
||||
@@ -2918,13 +3437,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tab_at_word_boundary_inserts_next_expected_keyword() {
|
||||
// `create ` → expects only `table`. Single candidate;
|
||||
// insert "table " with space, no memo.
|
||||
// `change ` → expects only `column`. Single candidate;
|
||||
// insert "column " with space, no memo. (Uses `change`, not
|
||||
// `create`: ADR-0045 made `create ` ambiguous — `table` vs
|
||||
// `m:n` — so it is no longer a single-candidate boundary.)
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "create ");
|
||||
type_str(&mut app, "change ");
|
||||
let actions = app.update(key(KeyCode::Tab));
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(app.input, "create table ");
|
||||
assert_eq!(app.input, "change column ");
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
@@ -3071,17 +3592,19 @@ mod tests {
|
||||
// Stage-8 follow-up #2 (testing-round-2): the
|
||||
// single-candidate-no-memo design lets the user chain
|
||||
// Tabs through unique completions without getting
|
||||
// stuck. From "cr", Tab → "create ", Tab → "create
|
||||
// table ". (Round 5 added the app-lifecycle commands —
|
||||
// stuck. From "ch", Tab → "change ", Tab → "change
|
||||
// column ". (Round 5 added the app-lifecycle commands —
|
||||
// single-letter prefixes like `i` are now ambiguous
|
||||
// (`insert` vs. `import`), so the test starts from a
|
||||
// disambiguated two-letter prefix.)
|
||||
// disambiguated two-letter prefix. `change` is used rather
|
||||
// than `create`: ADR-0045 made `create ` ambiguous (`table`
|
||||
// vs `m:n`), so it no longer chains as a unique completion.)
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "cr");
|
||||
type_str(&mut app, "ch");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "create ");
|
||||
assert_eq!(app.input, "change ");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "create table ");
|
||||
assert_eq!(app.input, "change column ");
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
@@ -5072,6 +5595,121 @@ mod tests {
|
||||
assert_eq!(app.input_cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationships_refreshed_event_updates_the_field() {
|
||||
// ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
|
||||
// App stores it for the sidebar relationships panel to render.
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
let mut app = App::new();
|
||||
assert!(app.relationships.is_empty());
|
||||
app.update(AppEvent::RelationshipsRefreshed(vec![
|
||||
crate::persistence::RelationshipSchema {
|
||||
name: "Customers_Orders".to_string(),
|
||||
parent_table: "Customers".to_string(),
|
||||
parent_columns: vec!["id".to_string()],
|
||||
child_table: "Orders".to_string(),
|
||||
child_columns: vec!["customer_id".to_string()],
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
},
|
||||
]));
|
||||
assert_eq!(app.relationships.len(), 1);
|
||||
assert_eq!(app.relationships[0].name, "Customers_Orders");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_o_cycles_navigation_focus() {
|
||||
// ADR-0046 DC1: Input → Tables → Relationships → Input.
|
||||
let mut app = App::new();
|
||||
assert_eq!(app.nav_focus, NavFocus::Input);
|
||||
let ctrl_o = || key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL);
|
||||
app.update(ctrl_o());
|
||||
assert_eq!(app.nav_focus, NavFocus::SidebarTables);
|
||||
app.update(ctrl_o());
|
||||
assert_eq!(app.nav_focus, NavFocus::SidebarRelationships);
|
||||
app.update(ctrl_o());
|
||||
assert_eq!(app.nav_focus, NavFocus::Input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_exits_navigation_mode() {
|
||||
let mut app = App::new();
|
||||
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
assert!(app.nav_focus.in_sidebar());
|
||||
app.update(key(KeyCode::Esc));
|
||||
assert_eq!(app.nav_focus, NavFocus::Input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_mode_ignores_input_keys() {
|
||||
// ADR-0046 DC4: the input is occluded; printable/Enter/Backspace
|
||||
// are inert while a sidebar panel is focused.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "select");
|
||||
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||
app.update(key(KeyCode::Char('x')));
|
||||
app.update(key(KeyCode::Backspace));
|
||||
let actions = app.update(key(KeyCode::Enter));
|
||||
assert_eq!(app.input, "select", "input untouched in navigation mode");
|
||||
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_scroll_keys_move_only_the_focused_panel() {
|
||||
// ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel.
|
||||
let mut app = App::new();
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
app.update(key(KeyCode::Down));
|
||||
app.update(key(KeyCode::Down));
|
||||
assert_eq!(app.tables_scroll, 2);
|
||||
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.tables_scroll, 1);
|
||||
// Up saturates at the top.
|
||||
app.update(key(KeyCode::Up));
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.tables_scroll, 0);
|
||||
// Switching focus moves the other panel instead.
|
||||
app.nav_focus = NavFocus::SidebarRelationships;
|
||||
app.update(key(KeyCode::Down));
|
||||
assert_eq!(app.relationships_scroll, 1);
|
||||
assert_eq!(app.tables_scroll, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_page_scroll_uses_the_panels_visible_rows() {
|
||||
// ADR-0046 DC3: PageUp/PageDown move by the last reported
|
||||
// visible-row count.
|
||||
let mut app = App::new();
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
app.last_tables_visible = 10;
|
||||
app.update(key(KeyCode::PageDown));
|
||||
assert_eq!(app.tables_scroll, 10);
|
||||
app.update(key(KeyCode::PageUp));
|
||||
assert_eq!(app.tables_scroll, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
|
||||
// ADR-0046 DA3: the horizontal scroll offset must not leak from
|
||||
// one command to the next. Submitting and recalling from history
|
||||
// both replace the buffer wholesale, so both reset it.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "a long command line that would have scrolled");
|
||||
app.input_scroll_offset = 25;
|
||||
submit(&mut app);
|
||||
assert_eq!(app.input_scroll_offset, 0, "submit resets the input scroll");
|
||||
|
||||
// Recall the submitted line from history — also a reset.
|
||||
type_str(&mut app, "another draft line entirely");
|
||||
app.input_scroll_offset = 25;
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(
|
||||
app.input_scroll_offset, 0,
|
||||
"history recall resets the input scroll"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_up_scrolls_output_back() {
|
||||
let mut app = App::new();
|
||||
|
||||
+77
@@ -42,6 +42,13 @@ pub struct Args {
|
||||
/// mode > the default (`simple`). Combines with `--resume` and
|
||||
/// a positional path; on collision the flag wins.
|
||||
pub mode: Option<Mode>,
|
||||
/// `--demo` (or `RDBMS_PLAYGROUND_DEMO` set truthy): enter
|
||||
/// **demonstration mode** (ADR-0047, issue #22). Off by default,
|
||||
/// zero footprint when off. When on, the app shows transient
|
||||
/// on-screen badges for otherwise-invisible keys (Tab, Enter, …)
|
||||
/// and enables the `Ctrl+]` stealth step-caption buffer — for
|
||||
/// screencasts and live teaching. The flag wins over the env var.
|
||||
pub demo: bool,
|
||||
}
|
||||
|
||||
/// Usage banner printed by `--help`.
|
||||
@@ -124,6 +131,12 @@ impl Args {
|
||||
let mut help = false;
|
||||
let mut no_undo = false;
|
||||
let mut mode: Option<Mode> = None;
|
||||
// Demonstration mode (ADR-0047): the env var is the default,
|
||||
// the `--demo` flag overrides it to on. Mirrors the
|
||||
// env-then-flag layering used for the log file above.
|
||||
let mut demo = env::var("RDBMS_PLAYGROUND_DEMO")
|
||||
.ok()
|
||||
.is_some_and(|v| demo_value_is_truthy(&v));
|
||||
let mut iter = iter.into_iter().map(Into::into);
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
@@ -136,6 +149,9 @@ impl Args {
|
||||
"--no-undo" => {
|
||||
no_undo = true;
|
||||
}
|
||||
"--demo" => {
|
||||
demo = true;
|
||||
}
|
||||
"--theme" => {
|
||||
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
|
||||
theme = match value.as_str() {
|
||||
@@ -194,10 +210,25 @@ impl Args {
|
||||
help,
|
||||
no_undo,
|
||||
mode,
|
||||
demo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `RDBMS_PLAYGROUND_DEMO` value enables demo mode.
|
||||
///
|
||||
/// Truthy for any value except the conventional "off" set
|
||||
/// (`0`/`false`/`no`/`off`, case-insensitively, and the empty
|
||||
/// string). So `RDBMS_PLAYGROUND_DEMO=1` and `=true` enable, while
|
||||
/// `=0` / `=false` explicitly disable — letting a value of `0` turn
|
||||
/// it off even if something upstream exported the variable.
|
||||
fn demo_value_is_truthy(value: &str) -> bool {
|
||||
!matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"" | "0" | "false" | "no" | "off"
|
||||
)
|
||||
}
|
||||
|
||||
fn default_theme() -> Theme {
|
||||
// NFR-7: support both backgrounds. For the walking skeleton we
|
||||
// honour an explicit `--theme` flag and the COLORFGBG env var
|
||||
@@ -391,6 +422,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ADR-0047 (issue #22): --demo demonstration mode ----
|
||||
|
||||
#[test]
|
||||
fn demo_flag_parses() {
|
||||
let args = Args::parse(["--demo"]).unwrap();
|
||||
assert!(args.demo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_defaults_off() {
|
||||
// Absent `--demo` (and absent env var in the test runner),
|
||||
// demo mode is off — zero footprint for real users.
|
||||
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
|
||||
assert!(!args.demo, "demo is off unless --demo or the env var is given");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_flag_coexists_with_positional_path() {
|
||||
let args = Args::parse(["--demo", "/home/me/MyProject"]).unwrap();
|
||||
assert!(args.demo);
|
||||
assert_eq!(
|
||||
args.project_path.as_deref(),
|
||||
Some(std::path::Path::new("/home/me/MyProject"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_flag_combines_with_resume_and_mode() {
|
||||
let args = Args::parse(["--resume", "--demo", "--mode", "advanced"]).unwrap();
|
||||
assert!(args.demo);
|
||||
assert!(args.resume);
|
||||
assert_eq!(args.mode, Some(Mode::Advanced));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_env_value_truthiness() {
|
||||
// Enabling values.
|
||||
for v in ["1", "true", "TRUE", "yes", "on", "anything", " 1 "] {
|
||||
assert!(demo_value_is_truthy(v), "{v:?} should enable demo mode");
|
||||
}
|
||||
// Disabling values.
|
||||
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
|
||||
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_double_dash_flag_errors_even_with_positional() {
|
||||
// Make sure the path-vs-flag distinction is robust:
|
||||
|
||||
+11
-3
@@ -31,6 +31,7 @@ use crate::mode::Mode;
|
||||
/// fragments the user thinks of as a single phrase:
|
||||
///
|
||||
/// - `1:n` — the opener for `add 1:n relationship`.
|
||||
/// - `m:n` — the opener for `create m:n relationship` (ADR-0045).
|
||||
/// - `double precision` — the lone two-word SQL type alias
|
||||
/// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word
|
||||
/// `Ident` validator never has to make sense of `double` alone).
|
||||
@@ -40,7 +41,7 @@ use crate::mode::Mode;
|
||||
/// composite replaces the bare opener rather than appearing
|
||||
/// alongside it.
|
||||
const COMPOSITE_CANDIDATES: &[(&str, &str)] =
|
||||
&[("1", "1:n"), ("double", "double precision")];
|
||||
&[("1", "1:n"), ("m", "m:n"), ("double", "double precision")];
|
||||
|
||||
/// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D).
|
||||
///
|
||||
@@ -1346,12 +1347,19 @@ mod tests {
|
||||
fn at_token_boundary_offers_next_expected_keyword() {
|
||||
// After `create ` advanced mode offers `table` (valid in both
|
||||
// modes) plus the SQL-only `unique` (`create unique index`) and
|
||||
// `index` — the shared-entry-word merge (ADR-0035 §4i d).
|
||||
// `index` — the shared-entry-word merge (ADR-0035 §4i d) — and
|
||||
// `m:n` (`create m:n relationship`, ADR-0045), surfaced as the
|
||||
// composite (the bare `m` opener is filtered).
|
||||
// `table` (Both) blocks before the Advanced-only `unique`/`index`.
|
||||
let cs = cands("create ", 7);
|
||||
assert_eq!(
|
||||
cs,
|
||||
vec!["table".to_string(), "unique".to_string(), "index".to_string()]
|
||||
vec![
|
||||
"table".to_string(),
|
||||
"unique".to_string(),
|
||||
"index".to_string(),
|
||||
"m:n".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -605,6 +605,13 @@ enum Request {
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
CreateM2nRelationship {
|
||||
t1: String,
|
||||
t2: String,
|
||||
name: Option<String>,
|
||||
source: Option<String>,
|
||||
reply: oneshot::Sender<Result<TableDescription, DbError>>,
|
||||
},
|
||||
DropRelationship {
|
||||
selector: RelationshipSelector,
|
||||
source: Option<String>,
|
||||
@@ -830,6 +837,13 @@ enum Request {
|
||||
source: crate::dsl::grammar::IdentSource,
|
||||
reply: oneshot::Sender<Result<Vec<String>, DbError>>,
|
||||
},
|
||||
/// All relationships as full schema records (name, parent/child
|
||||
/// tables + columns, referential actions). Feeds the sidebar
|
||||
/// relationships panel (ADR-0046 DB2); the walker only needs the
|
||||
/// names, which `ListNamesFor` already provides.
|
||||
ReadAllRelationships {
|
||||
reply: oneshot::Sender<Result<Vec<RelationshipSchema>, DbError>>,
|
||||
},
|
||||
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
|
||||
/// Replies with the metadata of the command that was undone, or
|
||||
/// `None` if there is nothing to undo (or undo is disabled).
|
||||
@@ -1420,6 +1434,29 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Generate a junction table for an m:n relationship between
|
||||
/// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo
|
||||
/// step (the junction + both relationships are built in a single
|
||||
/// `do_create_table`).
|
||||
pub async fn create_m2n_relationship(
|
||||
&self,
|
||||
t1: String,
|
||||
t2: String,
|
||||
name: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::CreateM2nRelationship {
|
||||
t1,
|
||||
t2,
|
||||
name,
|
||||
source,
|
||||
reply,
|
||||
})
|
||||
.await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
pub async fn drop_relationship(
|
||||
&self,
|
||||
selector: RelationshipSelector,
|
||||
@@ -1757,6 +1794,14 @@ impl Database {
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// All relationships as full schema records, for the sidebar
|
||||
/// relationships panel (ADR-0046 DB2).
|
||||
pub async fn read_all_relationships(&self) -> Result<Vec<RelationshipSchema>, DbError> {
|
||||
let (reply, recv) = oneshot::channel();
|
||||
self.send(Request::ReadAllRelationships { reply }).await?;
|
||||
recv.await.map_err(|_| DbError::WorkerGone)?
|
||||
}
|
||||
|
||||
/// Restore the most recent undo snapshot (ADR-0006 Amendment 1).
|
||||
/// `Ok(Some(meta))` reports the command that was undone;
|
||||
/// `Ok(None)` means nothing to undo (or undo is disabled).
|
||||
@@ -1921,7 +1966,7 @@ fn worker_loop(
|
||||
snapshots: Option<SnapshotStore>,
|
||||
mut rx: mpsc::Receiver<Request>,
|
||||
) {
|
||||
debug!("db worker started");
|
||||
info!("db worker started");
|
||||
// `conn` must be mutable: restoring a snapshot (undo/redo) writes
|
||||
// into the live connection via the backup API (`&mut`).
|
||||
let mut conn = conn;
|
||||
@@ -1968,7 +2013,7 @@ fn worker_loop(
|
||||
other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other),
|
||||
}
|
||||
}
|
||||
debug!("db worker exiting");
|
||||
info!("db worker exiting");
|
||||
}
|
||||
|
||||
/// Worker-side undo bracketing state for the request stream.
|
||||
@@ -2347,6 +2392,24 @@ fn handle_request(
|
||||
create_fk,
|
||||
));
|
||||
}
|
||||
Request::CreateM2nRelationship {
|
||||
t1,
|
||||
t2,
|
||||
name,
|
||||
source,
|
||||
reply,
|
||||
} => {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
do_create_m2n_relationship(
|
||||
conn,
|
||||
persistence,
|
||||
source.as_deref(),
|
||||
&t1,
|
||||
&t2,
|
||||
name.as_deref(),
|
||||
)
|
||||
});
|
||||
}
|
||||
Request::DropRelationship {
|
||||
selector,
|
||||
source,
|
||||
@@ -2726,6 +2789,9 @@ fn handle_request(
|
||||
let result = do_list_names_for(conn, source);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Request::ReadAllRelationships { reply } => {
|
||||
let _ = reply.send(read_all_relationships(conn));
|
||||
}
|
||||
// Undo/redo/peek/batch are intercepted in `worker_loop` (they
|
||||
// need `&mut conn` or persistent batch state) and never reach
|
||||
// here. Listed explicitly so a new variant still forces a
|
||||
@@ -3393,6 +3459,15 @@ fn do_create_table(
|
||||
check_constraints: &[String],
|
||||
foreign_keys: &[SqlForeignKey],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table");
|
||||
// A new table may not take an internal `__rdbms_*` name (it would be
|
||||
// filtered out of `list_tables` — a hidden orphan). The advanced-SQL
|
||||
// create path rejects this at parse, but the simple-mode DSL
|
||||
// `TABLE_NAME_NEW` slot has no validator, and `create m:n … as
|
||||
// <name>` (ADR-0045) reaches here too — so the shared executor is the
|
||||
// single place that closes every path (issue raised by the ADR-0045
|
||||
// /runda pass).
|
||||
reject_internal_table_name(name)?;
|
||||
if columns.is_empty() {
|
||||
// SQLite requires at least one column. The DSL grammar
|
||||
// already prevents this, but defending here too keeps
|
||||
@@ -3407,6 +3482,9 @@ fn do_create_table(
|
||||
// §5, sub-phase 4b). Self-references validate against the columns
|
||||
// being defined; other parents must already exist.
|
||||
let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?;
|
||||
if !resolved_fks.is_empty() {
|
||||
debug!(table = %name, fks = resolved_fks.len(), "create_table: foreign keys resolved + validated");
|
||||
}
|
||||
|
||||
// Inline `PRIMARY KEY` on the column when the table has a single
|
||||
// primary-key column and it is the **first** column — the exact
|
||||
@@ -3568,6 +3646,7 @@ fn do_drop_table(
|
||||
source: Option<&str>,
|
||||
name: &str,
|
||||
) -> Result<(), DbError> {
|
||||
debug!(table = %name, "drop_table");
|
||||
// Canonicalize the user-typed name to its stored case (and refuse a
|
||||
// non-existent / internal table), so the metadata DELETEs and the CSV
|
||||
// removal target the right name regardless of capitalization.
|
||||
@@ -3647,6 +3726,7 @@ fn do_add_column(
|
||||
table: &str,
|
||||
column: &ColumnSpec,
|
||||
) -> Result<AddColumnResult, DbError> {
|
||||
debug!(table = %table, column = %column.name, "add_column");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
if matches!(column.ty, Type::Serial | Type::ShortId) {
|
||||
@@ -3700,6 +3780,7 @@ fn do_add_plain_column(
|
||||
table: &str,
|
||||
spec: &ColumnSpec,
|
||||
) -> Result<AddColumnResult, DbError> {
|
||||
debug!(table = %table, column = %spec.name, "add_plain_column");
|
||||
// The plain `ALTER TABLE ADD COLUMN` path. `do_add_column`
|
||||
// only routes here when the constraints are ALTER-expressible
|
||||
// (no UNIQUE; NOT NULL only alongside a default), so the
|
||||
@@ -3752,6 +3833,7 @@ fn do_add_auto_generated_column(
|
||||
table: &str,
|
||||
spec: &ColumnSpec,
|
||||
) -> Result<AddColumnResult, DbError> {
|
||||
debug!(table = %table, column = %spec.name, "add_auto_generated_column");
|
||||
use rusqlite::types::Value as RV;
|
||||
|
||||
let ty = spec.ty;
|
||||
@@ -3883,6 +3965,7 @@ fn do_add_constrained_column_via_rebuild(
|
||||
table: &str,
|
||||
spec: &ColumnSpec,
|
||||
) -> Result<AddColumnResult, DbError> {
|
||||
debug!(table = %table, column = %spec.name, "add_constrained_column_via_rebuild");
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
if old_schema.columns.iter().any(|c| c.name == spec.name) {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
@@ -3984,6 +4067,7 @@ fn do_add_constraint(
|
||||
column: &str,
|
||||
constraint: &Constraint,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %table, column = %column, "add_constraint");
|
||||
// Canonicalize to the stored case (and refuse a non-existent /
|
||||
// internal `__rdbms_*` table as "no such table"), like the sibling
|
||||
// schema-mutation executors. Closes the simple `add constraint`
|
||||
@@ -4126,6 +4210,7 @@ fn do_drop_constraint(
|
||||
column: &str,
|
||||
kind: ConstraintKind,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %table, column = %column, "drop_constraint");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
@@ -4228,6 +4313,7 @@ fn do_set_column_default(
|
||||
column: &str,
|
||||
default_sql: &str,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %table, column = %column, "set_column_default");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
@@ -4617,6 +4703,7 @@ fn do_drop_column(
|
||||
column: &str,
|
||||
cascade: bool,
|
||||
) -> Result<DropColumnResult, DbError> {
|
||||
debug!(table = %table, column = %column, cascade, "drop_column");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let schema = read_schema(conn, table)?;
|
||||
@@ -4776,6 +4863,7 @@ fn do_rename_column(
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %table, old = %old, new = %new, "rename_column");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let schema = read_schema(conn, table)?;
|
||||
@@ -4898,6 +4986,7 @@ fn do_rename_table(
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(old = %old, new = %new, "rename_table");
|
||||
reject_internal_table_name(new)?;
|
||||
// Canonicalize the source to its stored case (and refuse a
|
||||
// non-existent / internal source as "no such table") — so a
|
||||
@@ -5086,6 +5175,7 @@ fn do_change_column_type(
|
||||
ty: Type,
|
||||
mode: ChangeColumnMode,
|
||||
) -> Result<ChangeColumnTypeResult, DbError> {
|
||||
debug!(table = %table, column = %column, ty = %ty, mode = ?mode, "change_column_type");
|
||||
// Canonicalize to the stored case (and refuse a non-existent /
|
||||
// internal `__rdbms_*` table as "no such table"), like the sibling
|
||||
// column executors. Closes the simple `change column` exposure and
|
||||
@@ -5888,6 +5978,7 @@ fn more_row(width: usize, more: usize) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn do_list_tables(conn: &Connection) -> Result<Vec<String>, DbError> {
|
||||
debug!("list_tables");
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_schema \
|
||||
@@ -5915,6 +6006,7 @@ fn do_show_relationship(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
) -> Result<Option<RelationshipDiagramData>, DbError> {
|
||||
debug!(name = %name, "show_relationship");
|
||||
let Some(rel) = read_all_relationships(conn)?
|
||||
.into_iter()
|
||||
.find(|r| r.name == name)
|
||||
@@ -5937,6 +6029,7 @@ fn do_show_list(
|
||||
kind: crate::dsl::command::ShowListKind,
|
||||
name: Option<&str>,
|
||||
) -> Result<Vec<String>, DbError> {
|
||||
debug!(kind = ?kind, name = ?name, "show_list");
|
||||
use crate::dsl::command::ShowListKind;
|
||||
// V5a: a named item shows one relationship/index's detail.
|
||||
if let Some(name) = name {
|
||||
@@ -6024,6 +6117,7 @@ fn do_show_one(
|
||||
kind: crate::dsl::command::ShowListKind,
|
||||
name: &str,
|
||||
) -> Result<Vec<String>, DbError> {
|
||||
debug!(kind = ?kind, name = %name, "show_one");
|
||||
use crate::dsl::command::ShowListKind;
|
||||
let mut lines = Vec::new();
|
||||
match kind {
|
||||
@@ -6802,6 +6896,7 @@ where
|
||||
C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>,
|
||||
M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>,
|
||||
{
|
||||
debug!(table = %table, cols = new_schema.columns.len(), "rebuild_table: begin (foreign_keys OFF, temp-copy primitive)");
|
||||
// foreign_keys=OFF must be set *outside* a transaction.
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF;")
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
@@ -6870,6 +6965,7 @@ where
|
||||
.map_err(DbError::from_rusqlite)?;
|
||||
let mut rows = check.query([]).map_err(DbError::from_rusqlite)?;
|
||||
if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? {
|
||||
warn!(table = %table, "rebuild_table: foreign_key_check failed; existing data violates new constraint, rolling back");
|
||||
return Err(DbError::Sqlite {
|
||||
message: format!(
|
||||
"foreign-key check failed after rebuild of `{table}`; \
|
||||
@@ -6882,6 +6978,7 @@ where
|
||||
drop(check);
|
||||
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
debug!(table = %table, indexes = captured_indexes.len(), "rebuild_table: committed (indexes recreated)");
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
@@ -6889,6 +6986,9 @@ where
|
||||
let pragma_result = conn
|
||||
.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.map_err(DbError::from_rusqlite);
|
||||
if let Err(e) = &pragma_result {
|
||||
warn!(table = %table, error = %e, "rebuild_table: failed to re-enable foreign_keys after rebuild");
|
||||
}
|
||||
|
||||
result.and(pragma_result)
|
||||
}
|
||||
@@ -7084,6 +7184,7 @@ fn resolve_fk_parent_columns(
|
||||
parent_pk: &[String],
|
||||
explicit: Option<&[String]>,
|
||||
child_arity: usize,
|
||||
inline: bool,
|
||||
) -> Result<Vec<String>, DbError> {
|
||||
if child_arity == 0 {
|
||||
return Err(DbError::Unsupported(
|
||||
@@ -7116,6 +7217,20 @@ fn resolve_fk_parent_columns(
|
||||
}
|
||||
};
|
||||
if parent_columns.len() != child_arity {
|
||||
// An inline column-level FK (`<col> REFERENCES …`) can only carry
|
||||
// the one column it sits on, so it can never satisfy a compound
|
||||
// key — point the user at the table-level form rather than the
|
||||
// generic arity message (ADR-0043 D4).
|
||||
if inline && parent_columns.len() > 1 {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"an inline column reference can only name one column, but \
|
||||
`{parent_table}`'s key has {n}. Use the table-level form \
|
||||
instead: `FOREIGN KEY (<columns>) REFERENCES \
|
||||
{parent_table} ({pk})`.",
|
||||
n = parent_columns.len(),
|
||||
pk = parent_columns.join(", "),
|
||||
)));
|
||||
}
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"{child_arity} foreign-key column(s) on the child side, but \
|
||||
`{parent_table}`'s key has {n}. A foreign key references every \
|
||||
@@ -7184,6 +7299,7 @@ fn resolve_create_table_fks(
|
||||
&parent_pk,
|
||||
fk.parent_columns.as_deref(),
|
||||
fk.child_columns.len(),
|
||||
fk.inline,
|
||||
)?;
|
||||
|
||||
// Each child column must be one of the columns being defined,
|
||||
@@ -7235,6 +7351,101 @@ fn resolve_create_table_fks(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Generate a junction table for an m:n relationship between `t1` and
|
||||
/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column
|
||||
/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a
|
||||
/// compound PK over all of them, and two `CASCADE` foreign keys, then
|
||||
/// hands the whole thing to [`do_create_table`] — so the junction table
|
||||
/// and both relationships are created in one transaction = one undo
|
||||
/// step. Self-referential m:n is refused (column-name collision); a
|
||||
/// PK-less parent is refused (nothing to reference).
|
||||
fn do_create_m2n_relationship(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
t1: &str,
|
||||
t2: &str,
|
||||
name: Option<&str>,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship");
|
||||
// Canonicalize both parents (refuse non-existent / internal tables).
|
||||
let canon_t1 = require_canonical_table(conn, t1)?;
|
||||
let t1 = canon_t1.as_str();
|
||||
let canon_t2 = require_canonical_table(conn, t2)?;
|
||||
let t2 = canon_t2.as_str();
|
||||
|
||||
// Self-referential m:n is OOS (ADR-0045): the two FK column sets
|
||||
// would collide on `{T}_{pkcol}`, needing directional names this
|
||||
// beginner convenience deliberately avoids.
|
||||
if t1.eq_ignore_ascii_case(t2) {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"an m:n relationship needs two different tables (got `{t1}` twice). \
|
||||
To link a table to itself, build the junction table by hand."
|
||||
)));
|
||||
}
|
||||
|
||||
let schema1 = read_schema(conn, t1)?;
|
||||
let schema2 = read_schema(conn, t2)?;
|
||||
|
||||
// Build one FK column per parent PK column (compound parents
|
||||
// contribute one each, ADR-0043) + the compound PK + the two FKs.
|
||||
let mut columns: Vec<ColumnSpec> = Vec::new();
|
||||
let mut primary_key: Vec<String> = Vec::new();
|
||||
let mut foreign_keys: Vec<SqlForeignKey> = Vec::new();
|
||||
for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] {
|
||||
// D7 parent-PK guard: advanced-mode SQL can create a PK-less
|
||||
// table; it cannot anchor an m:n relationship.
|
||||
if schema.primary_key.is_empty() {
|
||||
return Err(DbError::Unsupported(format!(
|
||||
"`{tbl}` has no primary key, so it cannot anchor an m:n relationship."
|
||||
)));
|
||||
}
|
||||
let mut child_columns: Vec<String> = Vec::new();
|
||||
for pkcol in &schema.primary_key {
|
||||
let pcol = schema
|
||||
.columns
|
||||
.iter()
|
||||
.find(|c| &c.name == pkcol)
|
||||
.ok_or_else(|| DbError::Sqlite {
|
||||
message: format!("no such column: {tbl}.{pkcol}"),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
})?;
|
||||
let pty = pcol.user_type.ok_or_else(|| {
|
||||
DbError::Unsupported("primary-key column has no user type metadata".to_string())
|
||||
})?;
|
||||
let col_name = format!("{tbl}_{pkcol}");
|
||||
columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type()));
|
||||
primary_key.push(col_name.clone());
|
||||
child_columns.push(col_name);
|
||||
}
|
||||
foreign_keys.push(SqlForeignKey {
|
||||
name: None,
|
||||
child_columns,
|
||||
parent_table: tbl.to_string(),
|
||||
parent_columns: Some(schema.primary_key.clone()),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::Cascade,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Junction name: explicit `as <name>` or the auto-name `{t1}_{t2}`.
|
||||
let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string);
|
||||
debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table");
|
||||
|
||||
do_create_table(
|
||||
conn,
|
||||
persistence,
|
||||
source,
|
||||
&junction,
|
||||
&columns,
|
||||
&primary_key,
|
||||
&[],
|
||||
&[],
|
||||
&foreign_keys,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn do_add_relationship(
|
||||
conn: &Connection,
|
||||
@@ -7249,6 +7460,7 @@ fn do_add_relationship(
|
||||
on_update: ReferentialAction,
|
||||
create_fk: bool,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(name = ?name, parent = %parent_table, child = %child_table, "add_relationship");
|
||||
// Canonicalize both endpoints to their stored case (and refuse a
|
||||
// non-existent / internal `__rdbms_*` table as "no such table"), like
|
||||
// the sibling schema-mutation executors — so the relationship metadata
|
||||
@@ -7268,6 +7480,7 @@ fn do_add_relationship(
|
||||
&parent_schema.primary_key,
|
||||
Some(parent_columns),
|
||||
child_columns.len(),
|
||||
false, // DSL `add relationship` is never an inline column FK
|
||||
)?;
|
||||
|
||||
// 2. Read child schema; refuse missing columns unless --create-fk.
|
||||
@@ -7409,6 +7622,7 @@ fn do_drop_relationship(
|
||||
source: Option<&str>,
|
||||
selector: &RelationshipSelector,
|
||||
) -> Result<Option<TableDescription>, DbError> {
|
||||
debug!(selector = ?selector, "drop_relationship");
|
||||
// Resolve to a single relationship row.
|
||||
let resolved: Option<(String, String, String, String, String)> = match selector {
|
||||
RelationshipSelector::Named { name } => conn
|
||||
@@ -7488,6 +7702,7 @@ fn do_alter_add_table_check(
|
||||
name: Option<&str>,
|
||||
expr_sql: &str,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %table, name = ?name, "alter_add_table_check");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
@@ -7593,6 +7808,7 @@ fn do_alter_add_unique(
|
||||
table: &str,
|
||||
columns: &[String],
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(table = %table, cols = ?columns, "alter_add_unique");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let old_schema = read_schema(conn, table)?;
|
||||
@@ -7660,6 +7876,7 @@ fn do_drop_constraint_by_name(
|
||||
table: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<TableDescription>, DbError> {
|
||||
debug!(table = %table, name = %name, "drop_constraint_by_name");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
|
||||
@@ -7781,6 +7998,7 @@ fn do_alter_add_foreign_key(
|
||||
name: Option<&str>,
|
||||
fk: &SqlForeignKey,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(child = %child_table, name = ?name, "alter_add_foreign_key");
|
||||
reject_internal_table_name(child_table)?;
|
||||
reject_internal_table_name(&fk.parent_table)?;
|
||||
// Resolve the parent columns: explicit must be the full PK (F-A);
|
||||
@@ -7792,6 +8010,7 @@ fn do_alter_add_foreign_key(
|
||||
&parent_pk,
|
||||
fk.parent_columns.as_deref(),
|
||||
fk.child_columns.len(),
|
||||
fk.inline, // false for `ALTER … ADD FOREIGN KEY` (table-level)
|
||||
)?;
|
||||
// Every child column must already exist for `ALTER … ADD FOREIGN
|
||||
// KEY` — there is no SQL spelling to auto-create one (`--create-fk`
|
||||
@@ -7891,6 +8110,7 @@ fn do_add_index(
|
||||
columns: &[String],
|
||||
unique: bool,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(name = ?name, table = %table, cols = ?columns, unique, "add_index");
|
||||
// 0. Canonicalize to the stored case (and refuse a non-existent /
|
||||
// internal `__rdbms_*` table) — both the simple `add index` and SQL
|
||||
// `CREATE INDEX` surfaces reach here, and the auto-index name embeds
|
||||
@@ -7979,6 +8199,7 @@ fn do_drop_index(
|
||||
source: Option<&str>,
|
||||
selector: &IndexSelector,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
debug!(selector = ?selector, "drop_index");
|
||||
let (index_name, table_name) = match selector {
|
||||
IndexSelector::Named { name } => {
|
||||
let lookup = conn.query_row(
|
||||
@@ -8067,6 +8288,7 @@ fn do_describe_table_request(
|
||||
}
|
||||
|
||||
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
|
||||
debug!(name = %name, "describe_table");
|
||||
// Column info — including the ADR-0029 constraints — comes
|
||||
// from `read_schema`, the single source of per-column truth
|
||||
// (it joins `pragma_table_info` with our type metadata and
|
||||
@@ -8422,6 +8644,7 @@ fn do_insert(
|
||||
user_columns: Option<&[String]>,
|
||||
user_values: &[Value],
|
||||
) -> Result<InsertResult, DbError> {
|
||||
debug!(table = %table, "insert");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let schema = read_schema(conn, table)?;
|
||||
@@ -8500,6 +8723,14 @@ fn do_insert(
|
||||
));
|
||||
}
|
||||
|
||||
debug!(
|
||||
table = %table,
|
||||
user_cols = user_cols.len(),
|
||||
total_cols = bindings.len(),
|
||||
autofilled = bindings.len() - user_cols.len(),
|
||||
"insert: column bindings resolved (serial/shortid auto-fill applied)"
|
||||
);
|
||||
|
||||
let cols_csv = bindings
|
||||
.iter()
|
||||
.map(|(c, _)| quote_ident(c))
|
||||
@@ -8579,6 +8810,7 @@ fn do_update(
|
||||
assignments: &[(String, Value)],
|
||||
filter: &RowFilter,
|
||||
) -> Result<UpdateResult, DbError> {
|
||||
debug!(table = %table, assignments = assignments.len(), "update");
|
||||
if assignments.is_empty() {
|
||||
return Err(DbError::InvalidValue(
|
||||
"UPDATE requires at least one assignment".to_string(),
|
||||
@@ -8678,6 +8910,7 @@ fn do_delete(
|
||||
table: &str,
|
||||
filter: &RowFilter,
|
||||
) -> Result<DeleteResult, DbError> {
|
||||
debug!(table = %table, "delete");
|
||||
let canonical_table = require_canonical_table(conn, table)?;
|
||||
let table = canonical_table.as_str();
|
||||
let schema = read_schema(conn, table)?;
|
||||
@@ -8732,6 +8965,14 @@ fn do_delete(
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
table = %table,
|
||||
rows_affected,
|
||||
cascaded_relationships = cascade.len(),
|
||||
rewritten_tables = rewritten_tables.len(),
|
||||
"delete: complete (cascade effects detected by child-count diff)"
|
||||
);
|
||||
|
||||
let changes = Changes {
|
||||
schema_dirty: false,
|
||||
rewritten_tables,
|
||||
@@ -9045,6 +9286,7 @@ fn do_sql_insert(
|
||||
returning: bool,
|
||||
literal_rows: &[Vec<Option<Value>>],
|
||||
) -> Result<InsertResult, DbError> {
|
||||
debug!(table = %target_table, returning, "sql_insert");
|
||||
debug!(sql = %sql, table = %target_table, returning, "sql_insert");
|
||||
let canonical_table = require_canonical_table(conn, target_table)?;
|
||||
let target_table = canonical_table.as_str();
|
||||
@@ -9161,6 +9403,7 @@ fn do_sql_update(
|
||||
returning: bool,
|
||||
set_literals: &[(String, Option<Value>)],
|
||||
) -> Result<UpdateResult, DbError> {
|
||||
debug!(table = %target_table, returning, "sql_update");
|
||||
debug!(sql = %sql, table = %target_table, returning, "sql_update");
|
||||
let canonical_table = require_canonical_table(conn, target_table)?;
|
||||
let target_table = canonical_table.as_str();
|
||||
@@ -9544,6 +9787,7 @@ fn do_query_data(
|
||||
filter: Option<&Expr>,
|
||||
limit: Option<u64>,
|
||||
) -> Result<DataResult, DbError> {
|
||||
debug!(table = %table, limit = ?limit, "query_data");
|
||||
let schema = read_schema(conn, table)?;
|
||||
let column_names: Vec<String> = schema.columns.iter().map(|c| c.name.clone()).collect();
|
||||
let column_types: Vec<Option<Type>> =
|
||||
@@ -9602,6 +9846,7 @@ fn format_cell(value: rusqlite::types::Value, ty: Option<Type>) -> Option<String
|
||||
/// executes the statement), and pairs the plan rows with a
|
||||
/// standard-SQL display form of the statement.
|
||||
fn do_explain_plan(conn: &Connection, query: &Command) -> Result<QueryPlan, DbError> {
|
||||
debug!("explain_plan");
|
||||
let (exec_sql, params) = match query {
|
||||
Command::ShowData {
|
||||
name,
|
||||
@@ -9855,6 +10100,7 @@ fn do_rebuild_from_text(
|
||||
source: Option<&str>,
|
||||
project_path: &Path,
|
||||
) -> Result<(), DbError> {
|
||||
debug!(path = %project_path.display(), "rebuild_from_text");
|
||||
let yaml_path = project_path.join(PROJECT_YAML);
|
||||
let data_dir = project_path.join(DATA_DIR);
|
||||
|
||||
@@ -10320,6 +10566,26 @@ mod tests {
|
||||
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_table_rejects_an_internal_name() {
|
||||
// A new table may not take an internal `__rdbms_*` name — it would
|
||||
// be hidden from `list_tables`. The advanced-SQL path rejects this
|
||||
// at parse; the shared executor guards every other path (the
|
||||
// simple-mode DSL slot and `create m:n … as`, ADR-0045).
|
||||
let db = db();
|
||||
let err = db
|
||||
.create_table(
|
||||
"__rdbms_sneaky".to_string(),
|
||||
vec![col("id", Type::Int)],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}");
|
||||
assert!(db.list_tables().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_table_removes_it_from_list() {
|
||||
let db = db();
|
||||
|
||||
@@ -45,6 +45,13 @@ pub struct SqlForeignKey {
|
||||
pub parent_columns: Option<Vec<String>>,
|
||||
pub on_delete: ReferentialAction,
|
||||
pub on_update: ReferentialAction,
|
||||
/// `true` for an inline column-level FK (`<col> REFERENCES …`),
|
||||
/// `false` for the table-level `FOREIGN KEY (…)` and `ALTER …`
|
||||
/// forms. An inline FK is single-column by construction, so when
|
||||
/// it references a compound key the resolver points the user at
|
||||
/// the table-level form rather than emitting the generic arity
|
||||
/// error (ADR-0043 D4).
|
||||
pub inline: bool,
|
||||
}
|
||||
|
||||
/// A column at table-creation time: a name, a user-facing
|
||||
@@ -270,6 +277,18 @@ pub enum Command {
|
||||
on_update: ReferentialAction,
|
||||
create_fk: bool,
|
||||
},
|
||||
/// Convenience: generate a junction table for a many-to-many
|
||||
/// relationship between `t1` and `t2` (ADR-0045 / C4). The
|
||||
/// executor builds a table with one FK column per parent PK
|
||||
/// column (named `{table}_{pkcol}`, typed via `fk_target_type`),
|
||||
/// a compound PK over all of them, and two `CASCADE` 1:n
|
||||
/// relationships — all in one `create table` (one undo step).
|
||||
/// `name` overrides the auto-generated junction name `{t1}_{t2}`.
|
||||
CreateM2nRelationship {
|
||||
t1: String,
|
||||
t2: String,
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Drop a relationship by either user-given/auto-generated
|
||||
/// name, or by positional reference to the FK endpoints.
|
||||
DropRelationship {
|
||||
@@ -908,6 +927,7 @@ impl Command {
|
||||
Self::RenameColumn { .. } => "rename column",
|
||||
Self::ChangeColumnType { .. } => "change column",
|
||||
Self::AddRelationship { .. } => "add relationship",
|
||||
Self::CreateM2nRelationship { .. } => "create m:n relationship",
|
||||
Self::DropRelationship { .. } => "drop relationship",
|
||||
Self::AddIndex { .. } => "add index",
|
||||
Self::DropIndex { .. } => "drop index",
|
||||
@@ -984,6 +1004,9 @@ impl Command {
|
||||
// table's "Referenced by" entry, which is what the
|
||||
// user looks at to confirm the relationship.
|
||||
Self::AddRelationship { parent_table, .. } => parent_table,
|
||||
// For m:n we focus on the first table; the executor builds
|
||||
// and returns the junction's structure regardless.
|
||||
Self::CreateM2nRelationship { t1, .. } => t1,
|
||||
Self::DropRelationship { selector } => match selector {
|
||||
RelationshipSelector::Endpoints { parent_table, .. } => parent_table,
|
||||
// For a named drop we don't know the parent table
|
||||
|
||||
+76
-3
@@ -1362,6 +1362,75 @@ pub static CREATE: CommandNode = CommandNode {
|
||||
help_id: Some("ddl.create"),
|
||||
usage_ids: &["parse.usage.create_table"],};
|
||||
|
||||
// =================================================================
|
||||
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
|
||||
// (ADR-0045 / C4). Generates an auto-named junction table with two FKs
|
||||
// + two 1:n relationships. A *separate* `CommandNode` under the shared
|
||||
// `create` entry word (the walker dispatches both); the `m` opener is a
|
||||
// `Literal` (not a keyword) so it never shadows an identifier, mirroring
|
||||
// the `1` in `add 1:n relationship`.
|
||||
// =================================================================
|
||||
|
||||
const M2N_T1: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "m2n_t1",
|
||||
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 M2N_T2: Node = Node::Ident {
|
||||
source: IdentSource::Tables,
|
||||
role: "m2n_t2",
|
||||
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,
|
||||
};
|
||||
// Optional `as <junction name>` — a *new* table name (the junction),
|
||||
// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source +
|
||||
// hint). The only `table_name` role in this path, so the builder reads
|
||||
// it directly as the junction name.
|
||||
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
|
||||
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
|
||||
|
||||
const CREATE_M2N_NODES: &[Node] = &[
|
||||
Node::Literal("m"),
|
||||
Node::Punct(':'),
|
||||
Node::Word(Word::keyword("n")),
|
||||
Node::Word(Word::keyword("relationship")),
|
||||
Node::Word(Word::keyword("from")),
|
||||
M2N_T1,
|
||||
Node::Word(Word::keyword("to")),
|
||||
M2N_T2,
|
||||
M2N_AS_NAME_OPT,
|
||||
];
|
||||
const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES);
|
||||
|
||||
fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
|
||||
Ok(Command::CreateM2nRelationship {
|
||||
t1: require_ident(path, "m2n_t1")?,
|
||||
t2: require_ident(path, "m2n_t2")?,
|
||||
name: ident(path, "table_name").map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub static CREATE_M2N: CommandNode = CommandNode {
|
||||
entry: Word::keyword("create"),
|
||||
shape: CREATE_M2N_SHAPE,
|
||||
ast_builder: build_create_m2n,
|
||||
help_id: Some("ddl.create_m2n"),
|
||||
usage_ids: &["parse.usage.create_m2n"],
|
||||
};
|
||||
|
||||
/// The friendly error for a column type without a preceding name —
|
||||
/// a structural impossibility given the grammar, defended anyway.
|
||||
fn sql_col_type_without_name() -> ValidationError {
|
||||
@@ -1557,7 +1626,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
// Inline FK is single-column (the column it sits on);
|
||||
// a compound FK uses the table-level form (ADR-0043 D4).
|
||||
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
|
||||
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column]));
|
||||
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
|
||||
}
|
||||
// Table-level `[constraint <name>] foreign key (<col>)
|
||||
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
|
||||
@@ -1587,7 +1656,8 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||||
items.next();
|
||||
}
|
||||
let fk = consume_fk_reference(&mut items, pending_fk_name.take(), child_columns);
|
||||
let fk =
|
||||
consume_fk_reference(&mut items, pending_fk_name.take(), child_columns, false);
|
||||
foreign_keys.push(fk);
|
||||
}
|
||||
// Track paren depth for element-boundary detection. The
|
||||
@@ -1704,6 +1774,7 @@ fn consume_fk_reference<'a, I>(
|
||||
items: &mut std::iter::Peekable<I>,
|
||||
name: Option<String>,
|
||||
child_columns: Vec<String>,
|
||||
inline: bool,
|
||||
) -> SqlForeignKey
|
||||
where
|
||||
I: Iterator<Item = &'a crate::dsl::walker::outcome::MatchedItem>,
|
||||
@@ -1752,6 +1823,7 @@ where
|
||||
parent_columns,
|
||||
on_delete,
|
||||
on_update,
|
||||
inline,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2454,7 +2526,8 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
|
||||
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
|
||||
items.next();
|
||||
}
|
||||
consume_fk_reference(&mut items, None, child_columns)
|
||||
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
|
||||
consume_fk_reference(&mut items, None, child_columns, false)
|
||||
}
|
||||
|
||||
pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||||
|
||||
@@ -657,6 +657,12 @@ pub fn usage_key_for_input_in_mode(
|
||||
if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) {
|
||||
return keys.iter().copied().find(|k| k.ends_with("relationship"));
|
||||
}
|
||||
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||
// — a letter, so the digit branch misses it, and its usage key ends
|
||||
// `…create_m2n` (not `relationship`).
|
||||
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
|
||||
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||
}
|
||||
// Otherwise the form word is an identifier — `column`,
|
||||
// `index`, `table`, `relationship` — matched against the
|
||||
// usage key's suffix.
|
||||
@@ -706,6 +712,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&ddl::RENAME, CommandCategory::Simple),
|
||||
(&ddl::CHANGE, CommandCategory::Simple),
|
||||
(&ddl::CREATE, CommandCategory::Simple),
|
||||
(&ddl::CREATE_M2N, CommandCategory::Simple),
|
||||
(&data::SHOW, CommandCategory::Simple),
|
||||
(&data::INSERT, CommandCategory::Simple),
|
||||
(&data::UPDATE, CommandCategory::Simple),
|
||||
@@ -852,6 +859,13 @@ mod usage_key_tests {
|
||||
),
|
||||
("show data T", "parse.usage.show_data"),
|
||||
("show table T", "parse.usage.show_table"),
|
||||
// `create` is multi-form (table vs m:n, ADR-0045): each typed
|
||||
// form resolves to its own usage key.
|
||||
("create table T with pk id(int)", "parse.usage.create_table"),
|
||||
(
|
||||
"create m:n relationship from A to B",
|
||||
"parse.usage.create_m2n",
|
||||
),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1004,6 +1004,16 @@ mod builder_tests {
|
||||
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
|
||||
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
|
||||
assert_eq!(fk.on_update, ReferentialAction::NoAction);
|
||||
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_level_fk_is_not_inline() {
|
||||
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
|
||||
// carry a multi-column reference and never triggers the inline
|
||||
// "use the table-level form" hint (ADR-0043 D4).
|
||||
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
|
||||
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+19
-3
@@ -12,6 +12,8 @@
|
||||
//! synthetic "unknown command" error when the input's first
|
||||
//! identifier-shape token isn't a registered entry word.
|
||||
|
||||
use tracing::trace;
|
||||
|
||||
use crate::dsl::command::Command;
|
||||
use crate::mode::Mode;
|
||||
|
||||
@@ -150,13 +152,27 @@ fn parse_command_inner(
|
||||
schema: Option<&crate::completion::SchemaCache>,
|
||||
mode: Mode,
|
||||
) -> Result<Command, ParseError> {
|
||||
// `trace`, not `debug`: parsing is a hot path — the live overlay /
|
||||
// completion (completion.rs) re-parse per keystroke, probing
|
||||
// candidates in a loop, so a per-parse `debug` line would flood. The
|
||||
// executed-command story lives at `debug` in db.rs (one per submit).
|
||||
trace!(
|
||||
len = input.len(),
|
||||
mode = ?mode,
|
||||
schema_aware = schema.is_some(),
|
||||
"parse: begin"
|
||||
);
|
||||
if input.trim().is_empty() {
|
||||
trace!("parse: empty input");
|
||||
return Err(ParseError::Empty);
|
||||
}
|
||||
if let Some(result) = try_walker_route(input, schema, mode) {
|
||||
return result;
|
||||
let result =
|
||||
try_walker_route(input, schema, mode).unwrap_or_else(|| Err(unknown_command_error(input)));
|
||||
match &result {
|
||||
Ok(cmd) => trace!(command = cmd.verb(), "parse: ok"),
|
||||
Err(e) => trace!(error = %e, "parse: rejected"),
|
||||
}
|
||||
Err(unknown_command_error(input))
|
||||
result
|
||||
}
|
||||
|
||||
/// Synthetic ParseError for inputs whose first identifier-shape
|
||||
|
||||
@@ -211,6 +211,21 @@ mod tests {
|
||||
assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_relationship_highlights_cleanly() {
|
||||
// ADR-0045: a valid `create m:n relationship` line classifies
|
||||
// with no Error runs; keywords are keywords and the table names
|
||||
// are identifiers (the `m:n` opener is a Literal, keyword-classed).
|
||||
let runs = run("create m:n relationship from A to B");
|
||||
assert!(
|
||||
!runs.iter().any(|(_, _, c)| *c == HighlightClass::Error),
|
||||
"no Error highlight on a valid m:n line: {runs:?}"
|
||||
);
|
||||
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
|
||||
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
|
||||
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_plus_identifier_via_walker() {
|
||||
// `show data Customers` walks end-to-end.
|
||||
|
||||
+61
-6
@@ -406,13 +406,28 @@ pub fn completion_probe_in_mode(
|
||||
// Mismatch and is naturally skipped — the viability check is the
|
||||
// gate, not the cursor depth.
|
||||
let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()];
|
||||
if mode == crate::mode::Mode::Advanced {
|
||||
{
|
||||
let s = skip_whitespace(source, 0);
|
||||
if let Some((kw_start, kw_end)) = consume_ident(source, s) {
|
||||
let entry = &source[kw_start..kw_end];
|
||||
let candidates = grammar::commands_for_entry_word(entry);
|
||||
if candidates.len() > 1 {
|
||||
use crate::dsl::grammar::CommandCategory;
|
||||
// Advanced mode merges DSL + SQL continuations across all
|
||||
// candidate nodes; Simple mode merges only when an entry word
|
||||
// has more than one DSL form (e.g. `create table` vs
|
||||
// `create m:n relationship`, ADR-0045). With a single DSL form
|
||||
// the committed node already carries every continuation, so
|
||||
// that case is left untouched (its `Both` mode-class too) —
|
||||
// keeping this zero-ripple for every existing command.
|
||||
let simple_count = candidates
|
||||
.iter()
|
||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||
.count();
|
||||
let run_merge = match mode {
|
||||
crate::mode::Mode::Advanced => candidates.len() > 1,
|
||||
crate::mode::Mode::Simple => simple_count > 1,
|
||||
};
|
||||
if run_merge {
|
||||
// (continuation word, produced-by-simple, produced-by-advanced)
|
||||
let mut tally: Vec<(&'static str, bool, bool)> = Vec::new();
|
||||
// Continuations that aren't keyword/literal-shaped
|
||||
@@ -422,6 +437,13 @@ pub fn completion_probe_in_mode(
|
||||
// for punctuation defaults to `Both`.
|
||||
let mut punct_tally: Vec<char> = Vec::new();
|
||||
for (_, node, category) in candidates {
|
||||
// Simple mode never offers advanced SQL continuations
|
||||
// (ADR-0030 §2); only DSL forms contribute.
|
||||
if mode == crate::mode::Mode::Simple
|
||||
&& category == CommandCategory::Advanced
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut sctx = context::WalkContext::with_schema(schema);
|
||||
sctx.mode = mode;
|
||||
let (res, _) =
|
||||
@@ -2720,12 +2742,45 @@ fn decide(
|
||||
// appended at the rendering layer (see
|
||||
// `advanced_alternative_note`), combining the DSL fix with
|
||||
// the mode hint.
|
||||
match simple.first() {
|
||||
Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode },
|
||||
None => {
|
||||
if simple.is_empty() {
|
||||
let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary);
|
||||
Decision::ThisIsSql { primary }
|
||||
return Decision::ThisIsSql { primary };
|
||||
}
|
||||
// An entry word may register more than one DSL form
|
||||
// (e.g. `create table` and `create m:n relationship`,
|
||||
// ADR-0045). Commit the first that fully matches or is
|
||||
// content-rejected (a `ValidationFailed` means the shape
|
||||
// fits but the content is invalid — that error must
|
||||
// surface), mirroring the advanced branch below. With a
|
||||
// single DSL form this reduces to "commit it": a lone
|
||||
// non-matching candidate falls through to the
|
||||
// furthest-progress step and is committed anyway, so its
|
||||
// positioned DSL error still surfaces (unchanged behaviour).
|
||||
for &(idx, node) in &simple {
|
||||
if matches!(
|
||||
scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema),
|
||||
WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. }
|
||||
) {
|
||||
return Decision::Commit { idx, node };
|
||||
}
|
||||
}
|
||||
// None matched — commit the furthest-progress candidate
|
||||
// (first on ties) so the surfaced DSL error is the most
|
||||
// informative.
|
||||
let mut best = simple[0];
|
||||
let mut best_progress =
|
||||
scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema);
|
||||
for &(idx, node) in &simple[1..] {
|
||||
let progress =
|
||||
scratch_progress(effective_source, kw_start, kw_end, node, mode, schema);
|
||||
if progress > best_progress {
|
||||
best = (idx, node);
|
||||
best_progress = progress;
|
||||
}
|
||||
}
|
||||
Decision::Commit {
|
||||
idx: best.0,
|
||||
node: best.1,
|
||||
}
|
||||
}
|
||||
crate::mode::Mode::Advanced => {
|
||||
|
||||
+54
@@ -15,6 +15,7 @@
|
||||
|
||||
use crate::app::EffectiveMode;
|
||||
use crate::dsl::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::dsl::Command;
|
||||
use crate::dsl::command::{
|
||||
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
|
||||
@@ -286,6 +287,31 @@ pub(crate) fn render_add_relationship(
|
||||
s
|
||||
}
|
||||
|
||||
/// The advanced-mode DSL→SQL teaching echo (ADR-0038) for `create m:n
|
||||
/// relationship` (ADR-0045): the single `CREATE TABLE` the junction
|
||||
/// expands to — every FK column, the compound primary key over them,
|
||||
/// and the two `CASCADE` foreign keys (m:n always cascades, D2). Built
|
||||
/// from the post-exec junction description (the resolved columns don't
|
||||
/// exist on the command), so it shows exactly what was created.
|
||||
pub(crate) fn render_create_m2n(
|
||||
junction: &str,
|
||||
columns: &[(String, Type)],
|
||||
primary_key: &[String],
|
||||
foreign_keys: &[(Vec<String>, String, Vec<String>)],
|
||||
) -> String {
|
||||
let mut parts: Vec<String> =
|
||||
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
|
||||
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
|
||||
for (child_columns, parent_table, parent_columns) in foreign_keys {
|
||||
parts.push(format!(
|
||||
"FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE",
|
||||
child_columns.join(", "),
|
||||
parent_columns.join(", "),
|
||||
));
|
||||
}
|
||||
format!("CREATE TABLE {junction} ({})", parts.join(", "))
|
||||
}
|
||||
|
||||
/// `ALTER TABLE <C> DROP CONSTRAINT <name>` — the `drop relationship`
|
||||
/// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an
|
||||
/// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre-
|
||||
@@ -1077,6 +1103,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_echo_renders_junction_and_round_trips() {
|
||||
// The advanced-mode teaching echo for `create m:n relationship`
|
||||
// (ADR-0045): the single CREATE TABLE the junction expands to,
|
||||
// compound PK + the two CASCADE FKs — and it is valid SQL.
|
||||
let sql = render_create_m2n(
|
||||
"Students_Courses",
|
||||
&[
|
||||
("Students_id".to_string(), Type::Int),
|
||||
("Courses_id".to_string(), Type::Int),
|
||||
],
|
||||
&["Students_id".to_string(), "Courses_id".to_string()],
|
||||
&[
|
||||
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
|
||||
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
sql,
|
||||
"CREATE TABLE Students_Courses (Students_id int, Courses_id int, \
|
||||
PRIMARY KEY (Students_id, Courses_id), \
|
||||
FOREIGN KEY (Students_id) REFERENCES Students (id) ON DELETE CASCADE ON UPDATE CASCADE, \
|
||||
FOREIGN KEY (Courses_id) REFERENCES Courses (id) ON DELETE CASCADE ON UPDATE CASCADE)"
|
||||
);
|
||||
// The echoed SQL is valid advanced-mode SQL (round-trips).
|
||||
assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. })));
|
||||
}
|
||||
|
||||
// --- expr / literal rendering ------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -165,6 +165,10 @@ pub enum AppEvent {
|
||||
/// posts this alongside `TablesRefreshed` after project
|
||||
/// load and after every successful DDL.
|
||||
SchemaCacheRefreshed(crate::completion::SchemaCache),
|
||||
/// Refreshed list of relationships as full schema records, for the
|
||||
/// sidebar relationships panel (ADR-0046 DB2). Posted by the runtime
|
||||
/// alongside `SchemaCacheRefreshed` after every schema refresh.
|
||||
RelationshipsRefreshed(Vec<crate::persistence::RelationshipSchema>),
|
||||
/// A persistence failure occurred (ADR-0015 §8). The
|
||||
/// application surfaces a fatal banner and exits cleanly so
|
||||
/// the message remains above the shell prompt.
|
||||
|
||||
@@ -190,6 +190,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.app.redo", &[]),
|
||||
("help.app.copy", &[]),
|
||||
("help.ddl.create", &[]),
|
||||
("help.ddl.create_m2n", &[]),
|
||||
("help.ddl.sql_create_table", &[]),
|
||||
("help.ddl.sql_drop_table", &[]),
|
||||
("help.ddl.sql_create_index", &[]),
|
||||
@@ -277,6 +278,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.add_relationship", &[]),
|
||||
("parse.usage.change_column", &[]),
|
||||
("parse.usage.create_table", &[]),
|
||||
("parse.usage.create_m2n", &[]),
|
||||
("parse.usage.sql_create_table", &[]),
|
||||
("parse.usage.sql_drop_table", &[]),
|
||||
("parse.usage.sql_create_index", &[]),
|
||||
@@ -441,6 +443,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("panel.hint_empty", &[]),
|
||||
("panel.hint_title", &[]),
|
||||
("panel.output_title", &[]),
|
||||
("panel.relationships_empty", &[]),
|
||||
("panel.relationships_title", &[]),
|
||||
("panel.tables_empty", &[]),
|
||||
("panel.tables_title", &[]),
|
||||
("status.no_project", &[]),
|
||||
|
||||
@@ -204,6 +204,9 @@ help:
|
||||
project's stored mode. Without it, the
|
||||
project's last-used mode is restored
|
||||
(default: simple).
|
||||
--demo Demonstration mode: show on-screen badges
|
||||
for otherwise-invisible keys (Tab, Enter,
|
||||
...) — for screencasts and live teaching.
|
||||
|
||||
App-level commands (typed inside the app, available in both modes):
|
||||
quit Exit cleanly.
|
||||
@@ -279,6 +282,9 @@ help:
|
||||
ddl:
|
||||
create: |-
|
||||
create table <T> with pk [<col>(<type>), ...] — create a table
|
||||
create_m2n: |-
|
||||
create m:n relationship from <T1> to <T2> [as <name>]
|
||||
— build a junction table linking two tables
|
||||
sql_create_table: |-
|
||||
create table [if not exists] <T> (
|
||||
<col> <type> [not null] [unique] [primary key] [default <expr>] [check (<expr>)] [references <P>[(<col>)]], ...
|
||||
@@ -523,6 +529,7 @@ parse:
|
||||
# placeholders. ADR-0009's surface conventions apply.
|
||||
usage:
|
||||
create_table: "create table <Name> with pk [<col>(<type>)[, ...]]"
|
||||
create_m2n: "create m:n relationship from <Table1> to <Table2> [as <Name>]"
|
||||
# Terse one-line synopsis (issue #12): the full grammar — every
|
||||
# column- and table-level constraint — lives in `help.ddl.sql_create_table`.
|
||||
sql_create_table: "create table [if not exists] <Name> (<col> <type> [constraints], ...)"
|
||||
@@ -849,6 +856,8 @@ status:
|
||||
panel:
|
||||
tables_title: "Tables"
|
||||
tables_empty: "(none yet)"
|
||||
relationships_title: "Relationships"
|
||||
relationships_empty: "(none)"
|
||||
hint_empty: "Type a command — press Tab for options, `help` for a list"
|
||||
# Panel titles for the output and hint panels (rendered inside
|
||||
# the rounded border, hence the leading/trailing space).
|
||||
|
||||
@@ -882,6 +882,30 @@ mod tests {
|
||||
assert!(f.headline.contains("`99`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_child_side_renders_every_column_of_a_compound_key() {
|
||||
// ADR-0043 residual: a compound-FK violation carries the
|
||||
// comma-joined column + value lists in the single-column facts
|
||||
// slots, so the headline names every pair, not just the first.
|
||||
let err = sqlite(
|
||||
"FOREIGN KEY constraint failed",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let mut ctx = ctx_with(Operation::Insert);
|
||||
ctx.parent_table = Some("Region".to_string());
|
||||
ctx.parent_column = Some("country, code".to_string());
|
||||
ctx.value = Some("7, 8".to_string());
|
||||
let f = translate(&err, &ctx);
|
||||
assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline);
|
||||
assert!(f.headline.contains("Region"));
|
||||
assert!(
|
||||
f.headline.contains("country, code"),
|
||||
"both parent columns must appear: {}",
|
||||
f.headline
|
||||
);
|
||||
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_with_delete_op_renders_parent_side_wording() {
|
||||
let err = sqlite(
|
||||
|
||||
@@ -6,6 +6,38 @@
|
||||
//! environment variable; if neither is set we default to
|
||||
//! `~/.rdbms-playground/playground.log` and create directories as
|
||||
//! needed.
|
||||
//!
|
||||
//! ## Level conventions (X1 — `requirements.md`)
|
||||
//!
|
||||
//! Instrumentation across the tree follows a consistent level
|
||||
//! discipline so the default `info` filter stays quiet and
|
||||
//! `RDBMS_PLAYGROUND_LOG=debug` (or `=trace`) is a rich, layered
|
||||
//! diagnostic stream. The env filter (`RDBMS_PLAYGROUND_LOG`,
|
||||
//! full `EnvFilter` syntax) controls this independently of the
|
||||
//! file path above; the default is `info`.
|
||||
//!
|
||||
//! - **`error!`** — unrecoverable failure (fatal persistence, a
|
||||
//! panic-equivalent). The process is going down or a command is
|
||||
//! hard-failing.
|
||||
//! - **`warn!`** — recoverable failure or a fallback taken (a
|
||||
//! snapshot couldn't be staged, a `PRAGMA` couldn't be restored,
|
||||
//! an integrity check rolled a rebuild back).
|
||||
//! - **`info!`** — low-volume lifecycle, visible by default: db
|
||||
//! worker start/exit, project create/open, "logging initialised".
|
||||
//! - **`debug!`** — the bulk of instrumentation, one line per
|
||||
//! *executed* command and the decision points within it (executor
|
||||
//! entry with key params, autofill/cascade summaries, the
|
||||
//! rebuild-table primitive, persistence writes, render-mode
|
||||
//! choice). Off by default.
|
||||
//! - **`trace!`** — hot paths only: per-keystroke parsing
|
||||
//! (`dsl::parser`), per-key input handling (`app`), per-refresh
|
||||
//! table reads. A firehose; never on except when debugging that
|
||||
//! specific layer.
|
||||
//!
|
||||
//! Rule of thumb for new code: a loop logs a single summary count,
|
||||
//! never per-iteration at `debug`/`info`. Logs are developer-facing,
|
||||
//! so naming the engine (SQLite/PRAGMA) is fine here even though the
|
||||
//! "no engine name" rule (ADR-0002) forbids it in user-facing strings.
|
||||
|
||||
use std::fs::{File, OpenOptions, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -19,6 +19,8 @@ use std::fs;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
use crate::dsl::types::Type;
|
||||
use crate::mode::Mode;
|
||||
@@ -338,6 +340,7 @@ impl Persistence {
|
||||
/// renames over the destination.
|
||||
pub fn write_schema(&self, schema: &SchemaSnapshot) -> Result<(), PersistenceError> {
|
||||
let body = yaml::serialize_schema(schema);
|
||||
debug!(bytes = body.len(), "persist: write project.yaml (atomic)");
|
||||
atomic_write(&self.project_path.join(PROJECT_YAML), body.as_bytes())
|
||||
}
|
||||
|
||||
@@ -355,8 +358,10 @@ impl Persistence {
|
||||
/// with files they didn't ask for.
|
||||
pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> {
|
||||
if table.rows.is_empty() {
|
||||
debug!(table = %table.name, "persist: table empty -> removing CSV (no data, no CSV)");
|
||||
return self.delete_table_data(&table.name);
|
||||
}
|
||||
debug!(table = %table.name, rows = table.rows.len(), "persist: write data/<table>.csv (atomic)");
|
||||
let data_dir = self.project_path.join(DATA_DIR);
|
||||
fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io {
|
||||
operation: "create",
|
||||
@@ -394,6 +399,7 @@ impl Persistence {
|
||||
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
||||
debug!(len = command_text.len(), "persist: append ok record to history.log");
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
@@ -411,6 +417,7 @@ impl Persistence {
|
||||
history::utc_iso8601_now(),
|
||||
history::STATUS_ERR,
|
||||
);
|
||||
debug!(len = command_text.len(), "persist: append err record to history.log");
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
|
||||
+144
-23
@@ -11,7 +11,7 @@
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
@@ -53,6 +53,24 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
|
||||
/// reappears once typing stops (ADR-0027 §3).
|
||||
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// How long a demo-mode keystroke badge stays on screen before it
|
||||
/// fades on its own (ADR-0047 D5). Long enough to read in a screencast
|
||||
/// or in front of a class; short enough that a trailing `wait` in a
|
||||
/// cast ends on a clean frame.
|
||||
const DEMO_BADGE_TTL: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// The nearest (soonest) of two optional deadlines (ADR-0047 D5) — the
|
||||
/// instant the event loop should next wake to service a timer. `None`
|
||||
/// when neither is set (the loop then blocks on `recv`). Pure, so the
|
||||
/// scheduling decision is unit-testable without the loop.
|
||||
fn nearest_deadline(a: Option<Instant>, b: Option<Instant>) -> Option<Instant> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The input-validity indicator's debounce state machine
|
||||
/// (ADR-0027 §3, step E).
|
||||
///
|
||||
@@ -216,6 +234,9 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
let db_existed = db_path.exists();
|
||||
// Undo is on unless `--no-undo` (ADR-0006 Amendment 1).
|
||||
let undo_enabled = !args.no_undo;
|
||||
// Demonstration mode under `--demo` / `RDBMS_PLAYGROUND_DEMO`
|
||||
// (ADR-0047). Off by default; threaded onto the `App` in run_loop.
|
||||
let demo_mode = args.demo;
|
||||
let database =
|
||||
Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled)
|
||||
.context("open database")?;
|
||||
@@ -273,6 +294,7 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
initial_events,
|
||||
undo_enabled,
|
||||
resolved_mode,
|
||||
demo_mode,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||
@@ -331,6 +353,7 @@ async fn run_loop(
|
||||
initial_events: Vec<AppEvent>,
|
||||
undo_enabled: bool,
|
||||
initial_mode: crate::mode::Mode,
|
||||
demo_mode: bool,
|
||||
) -> Result<Option<String>> {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||
@@ -339,6 +362,8 @@ async fn run_loop(
|
||||
app.project_name = Some(project_display_name);
|
||||
app.project_is_temp = project_is_temp;
|
||||
app.undo_enabled = undo_enabled;
|
||||
// ADR-0047: enable the demo overlays for this session under `--demo`.
|
||||
app.demo_mode = demo_mode;
|
||||
// Start in the resolved input mode (ADR-0015 mode-restore
|
||||
// amendment, issue #14): `--mode` > stored project mode >
|
||||
// default. `Persistence` already carries the same value, so the
|
||||
@@ -376,6 +401,17 @@ async fn run_loop(
|
||||
// no wake-ups. See `IndicatorDebounce` for the decision
|
||||
// logic; `app.input_indicator` mirrors it for the renderer.
|
||||
let mut debounce = IndicatorDebounce::default();
|
||||
// ADR-0027 §3 + ADR-0047 D5: absolute deadlines for the two timed
|
||||
// wake-ups — the indicator debounce and the demo keystroke-badge
|
||||
// expiry. The loop time-boxes `recv` on the *nearest* of them and,
|
||||
// on elapse, services whichever actually fired. Tracking them as
|
||||
// `Instant`s (rather than one fixed `timeout` duration) lets the
|
||||
// shorter badge timer fire without prematurely settling the longer
|
||||
// debounce, and vice-versa. Both `None` ⇒ block on `recv` (no idle
|
||||
// wake-ups).
|
||||
let mut debounce_deadline: Option<Instant> = None;
|
||||
let mut badge_deadline: Option<Instant> = None;
|
||||
let mut last_badge_seq: u64 = app.demo_badge_seq;
|
||||
// Long-lived native clipboard for the `copy` command (ADR-0041).
|
||||
// Created lazily on first copy (so an OSC-52-only session never
|
||||
// opens an X11 connection) and kept alive for the session — the
|
||||
@@ -383,25 +419,36 @@ async fn run_loop(
|
||||
// handle, so it must outlive each write.
|
||||
let mut native_clipboard = crate::clipboard::SystemClipboard::new();
|
||||
loop {
|
||||
let event = if debounce.is_armed() {
|
||||
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
|
||||
let event = match nearest_deadline(debounce_deadline, badge_deadline) {
|
||||
None => match event_rx.recv().await {
|
||||
Some(event) => event,
|
||||
None => break,
|
||||
},
|
||||
Some(deadline) => {
|
||||
let wait = deadline.saturating_duration_since(Instant::now());
|
||||
match tokio::time::timeout(wait, event_rx.recv()).await {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => break,
|
||||
Err(_elapsed) => {
|
||||
// Typing has been quiet for the debounce
|
||||
// interval — settle the indicator.
|
||||
let now = Instant::now();
|
||||
// ADR-0047 D5: the keystroke badge has aged out.
|
||||
if badge_deadline.is_some_and(|d| d <= now) {
|
||||
app.demo_badge = None;
|
||||
badge_deadline = None;
|
||||
}
|
||||
// ADR-0027 §3: typing has paused for the debounce
|
||||
// interval — settle the validity indicator.
|
||||
if debounce_deadline.is_some_and(|d| d <= now) {
|
||||
debounce.settle(app.input_validity_verdict());
|
||||
app.input_indicator = debounce.visible();
|
||||
debounce_deadline = None;
|
||||
}
|
||||
terminal
|
||||
.draw(|f| ui::render(&mut app, &theme, f))
|
||||
.context("redraw")?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match event_rx.recv().await {
|
||||
Some(event) => event,
|
||||
None => break,
|
||||
}
|
||||
};
|
||||
let is_key = matches!(event, AppEvent::Key(_));
|
||||
@@ -584,6 +631,23 @@ async fn run_loop(
|
||||
// pauses; non-key events leave it untouched.
|
||||
debounce.note_event(is_key);
|
||||
app.input_indicator = debounce.visible();
|
||||
// Keep the debounce deadline in lock-step with `is_armed()`,
|
||||
// restarting it on every event while armed (preserving the prior
|
||||
// behaviour) and clearing it once the indicator is visible again.
|
||||
debounce_deadline = debounce
|
||||
.is_armed()
|
||||
.then(|| Instant::now() + INDICATOR_DEBOUNCE);
|
||||
// ADR-0047 D5: (re)arm the badge timer whenever `update()` set a
|
||||
// fresh badge. `demo_badge_seq` bumps even for the same label
|
||||
// twice, so a repeated key restarts the timer rather than letting
|
||||
// a stale deadline expire it early.
|
||||
if app.demo_badge_seq != last_badge_seq {
|
||||
last_badge_seq = app.demo_badge_seq;
|
||||
badge_deadline = app
|
||||
.demo_badge
|
||||
.is_some()
|
||||
.then(|| Instant::now() + DEMO_BADGE_TTL);
|
||||
}
|
||||
terminal
|
||||
.draw(|f| ui::render(&mut app, &theme, f))
|
||||
.context("redraw")?;
|
||||
@@ -1079,6 +1143,13 @@ async fn refresh_schema_cache(
|
||||
) {
|
||||
let cache = build_schema_cache(database).await;
|
||||
let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await;
|
||||
// ADR-0046 DB2: full relationship records for the sidebar panel.
|
||||
// Best-effort — a failed read leaves the panel empty.
|
||||
if let Ok(relationships) = database.read_all_relationships().await {
|
||||
let _ = event_tx
|
||||
.send(AppEvent::RelationshipsRefreshed(relationships))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `SchemaCache` snapshot from the live database.
|
||||
@@ -1832,6 +1903,24 @@ fn build_schema_echo(
|
||||
.map(|(name, child_table)| {
|
||||
vec![crate::echo::render_drop_relationship(name, child_table)]
|
||||
}),
|
||||
// `create m:n relationship` (ADR-0045): the resolved junction
|
||||
// columns/FKs only exist on the post-exec description, so the
|
||||
// teaching echo is rendered from it (not `command_to_sql`).
|
||||
Command::CreateM2nRelationship { .. } => description.map(|desc| {
|
||||
let columns: Vec<(String, crate::dsl::types::Type)> = desc
|
||||
.columns
|
||||
.iter()
|
||||
.filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty)))
|
||||
.collect();
|
||||
let primary_key: Vec<String> =
|
||||
desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect();
|
||||
let foreign_keys: Vec<(Vec<String>, String, Vec<String>)> = desc
|
||||
.outbound_relationships
|
||||
.iter()
|
||||
.map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone()))
|
||||
.collect();
|
||||
vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)]
|
||||
}),
|
||||
// Everything else (Bucket A pure-Command, plus the no-echo Bucket C
|
||||
// variants like `Sql*` / `ShowTable`) routes through the existing
|
||||
// `echo::command_to_sql` — wrapping its `Option<String>` to fit the
|
||||
@@ -2017,23 +2106,34 @@ async fn enrich_fk_violation(
|
||||
};
|
||||
facts.table = Some(table.clone());
|
||||
for rel in outbound {
|
||||
// The friendly FK-error facts model is single-column
|
||||
// (ADR-0019); for a compound FK (ADR-0043) we enrich
|
||||
// from the first column pair — the error still surfaces,
|
||||
// richer multi-column enrichment is a later refinement.
|
||||
let Some(local_col) = rel.local_columns.first().cloned() else {
|
||||
// Identify the violated FK by the first local column the
|
||||
// user supplied a value for (SQLite names no column in the
|
||||
// error). The single-column facts slots then carry the
|
||||
// comma-joined lists so a compound FK (ADR-0043) names
|
||||
// *every* child->parent column pair, not just the first.
|
||||
let Some(first_local) = rel.local_columns.first().cloned() else {
|
||||
continue;
|
||||
};
|
||||
let value =
|
||||
user_value_for_column_with_schema(database, command, table, &local_col).await;
|
||||
if let Some(v) = value {
|
||||
facts.column = Some(local_col);
|
||||
facts.parent_table = Some(rel.other_table);
|
||||
facts.parent_column = rel.other_columns.into_iter().next();
|
||||
facts.value = Some(v.to_string());
|
||||
break;
|
||||
let Some(first_val) =
|
||||
user_value_for_column_with_schema(database, command, table, &first_local).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Matched. Gather the remaining pairs' values in order.
|
||||
let mut values = vec![first_val.to_string()];
|
||||
for local_col in rel.local_columns.iter().skip(1) {
|
||||
if let Some(v) =
|
||||
user_value_for_column_with_schema(database, command, table, local_col).await
|
||||
{
|
||||
values.push(v.to_string());
|
||||
}
|
||||
}
|
||||
facts.column = Some(rel.local_columns.join(", "));
|
||||
facts.parent_table = Some(rel.other_table);
|
||||
facts.parent_column = Some(rel.other_columns.join(", "));
|
||||
facts.value = Some(values.join(", "));
|
||||
break;
|
||||
}
|
||||
// For UPDATE, if no outbound match was found we may
|
||||
// be in the parent-side case (updating a column
|
||||
// children reference). Check inbound as a fallback.
|
||||
@@ -2531,6 +2631,7 @@ async fn execute_command_typed(
|
||||
command: Command,
|
||||
source: String,
|
||||
) -> Result<CommandOutcome, DbError> {
|
||||
debug!(verb = command.verb(), "execute command (routing to worker)");
|
||||
let src = Some(source);
|
||||
match command {
|
||||
Command::CreateTable {
|
||||
@@ -2645,6 +2746,10 @@ async fn execute_command_typed(
|
||||
)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::CreateM2nRelationship { t1, t2, name } => database
|
||||
.create_m2n_relationship(t1, t2, name, src)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::DropRelationship { selector } => database
|
||||
.drop_relationship(selector, src)
|
||||
.await
|
||||
@@ -2964,8 +3069,24 @@ fn teardown_terminal(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::IndicatorDebounce;
|
||||
use super::{IndicatorDebounce, nearest_deadline};
|
||||
use crate::dsl::walker::Severity;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn nearest_deadline_picks_the_soonest_or_none() {
|
||||
let now = Instant::now();
|
||||
let soon = now + Duration::from_millis(100);
|
||||
let later = now + Duration::from_millis(500);
|
||||
// Neither armed ⇒ block (None).
|
||||
assert_eq!(nearest_deadline(None, None), None);
|
||||
// One armed ⇒ that one, regardless of order.
|
||||
assert_eq!(nearest_deadline(Some(soon), None), Some(soon));
|
||||
assert_eq!(nearest_deadline(None, Some(soon)), Some(soon));
|
||||
// Both armed ⇒ the soonest, regardless of order.
|
||||
assert_eq!(nearest_deadline(Some(soon), Some(later)), Some(soon));
|
||||
assert_eq!(nearest_deadline(Some(later), Some(soon)), Some(soon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_hidden_and_disarmed() {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 1540
|
||||
assertion_line: 2326
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||
│(none yet) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ ADVANCED ────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` │
|
||||
│ ││for a list │
|
||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ ADVANCED ────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · mode simple switch · Ctrl-C quit
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 1523
|
||||
assertion_line: 2309
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||
│(none yet) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` │
|
||||
│ ││for a list │
|
||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 1531
|
||||
assertion_line: 2317
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||
│(none yet) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` │
|
||||
│ ││for a list │
|
||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ [TAB] │
|
||||
│ │
|
||||
│ │
|
||||
│ Completing the name │
|
||||
│ │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ [ENTER] │
|
||||
│ │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ [TAB] │
|
||||
│ │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ Now press Tab to complete the table name │
|
||||
│ │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ This is a deliberately long step caption │
|
||||
│ that must wrap onto several lines and │
|
||||
│ then be clipped to three with an… │
|
||||
│ │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
+23
-23
@@ -1,29 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 1583
|
||||
assertion_line: 2369
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||
│(none yet) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||
│ ││insert into T values (1, 'hi', null) --all-r │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ────────────────────────────────────────────╮
|
||||
│ ││after `insert into T values (1, 'hi', null)`, │
|
||||
│ ││expected end of input — usage: insert into │
|
||||
│ ││<Table> [(<col>[, ...])] [values] (<value>[, ...])│
|
||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮
|
||||
│insert into T values (1, 'hi', null) --all-rows $ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│after `insert into T values (1, 'hi', null)`, expected end of input — usage: │
|
||||
│insert into <Table> [(<col>[, ...])] [values] (<value>[, ...]) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2967
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
|
||||
│Customers │ │
|
||||
│Orders │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ │ ─────────────────────────────────╯
|
||||
│ │ ─────────────────────────────────╮
|
||||
╰───────────────────────────────────────────╯ │
|
||||
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
|
||||
│Customers_Orders │ ─────────────────────────────────╮
|
||||
│ Customers.id -> │ ` for a list │
|
||||
│ Orders.customer_id │ │
|
||||
╰───────────────────────────────────────────╯ ─────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
@@ -1,28 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2385
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||
│(none yet) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Advanced: ───────────────────────────────────────╮
|
||||
│ ││: sel │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ────────────────────────────────────────────╮
|
||||
│ ││select │
|
||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Advanced: ───────────────────────────────────────────────────────────────────╮
|
||||
│: sel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│select │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 1841
|
||||
assertion_line: 2679
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
│Customers ││[simple] create table Customers ✓ │
|
||||
│Orders ││[system] Customers │
|
||||
│ ││[system] id serial [PK] │
|
||||
@@ -17,13 +17,13 @@ expression: snapshot
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||
│ │╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` for a list │
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` │
|
||||
│ ││for a list │
|
||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 1613
|
||||
assertion_line: 2399
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||
│(none yet) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ╭ Rebuild project ─────────────────────────────────────────╮ │
|
||||
│ │ │ │
|
||||
│ │3 tables and 47 rows will be reconstructed; the existing │ │
|
||||
@@ -17,13 +17,13 @@ expression: snapshot
|
||||
│ │ │ │
|
||||
│ │Continue? │ │
|
||||
│ │ │ │
|
||||
│ │[Y] Yes [N] No Esc cancel │─────────╯
|
||||
│ ╰──────────────────────────────────────────────────────────╯─────────╮
|
||||
│ ││ │
|
||||
│ │╰──────────────────────────────────────────────────╯
|
||||
│ │╭ Hint ────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` │
|
||||
│ ││for a list │
|
||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||
╰─────────│[Y] Yes [N] No Esc cancel │─────────╯
|
||||
╭ SIMPLE ─╰──────────────────────────────────────────────────────────╯─────────╮
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2789
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
│Customers ││ │
|
||||
│Orders ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ │╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │
|
||||
│ Orders.customer_id ││ │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2265
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────╯
|
||||
╭ SIMPLE ──────────────────────────────────────────────────╮
|
||||
│select * from Customers where id = 12345 and name = │
|
||||
│'Alice Wonderland' │
|
||||
╰──────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────╮
|
||||
│`select` is SQL — available in advanced mode. Switch │
|
||||
│with `mode advanced`, or prefix the line with `:` to run… │
|
||||
╰──────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch ·
|
||||
@@ -20,6 +20,16 @@ use ratatui::style::Color;
|
||||
|
||||
use crate::dsl::grammar::HighlightClass;
|
||||
|
||||
/// Foreground of the demonstration-mode overlays (ADR-0047 D4).
|
||||
///
|
||||
/// Deliberately a fixed, theme-independent high-contrast pair — black
|
||||
/// on yellow — so the badge / caption boxes are hard to overlook in a
|
||||
/// screencast on any background.
|
||||
pub const DEMO_OVERLAY_FG: Color = Color::Black;
|
||||
/// Background of the demonstration-mode overlays (ADR-0047 D4); see
|
||||
/// [`DEMO_OVERLAY_FG`].
|
||||
pub const DEMO_OVERLAY_BG: Color = Color::Rgb(0xFF, 0xD7, 0x00);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Background {
|
||||
Light,
|
||||
|
||||
@@ -137,6 +137,7 @@ fn sql_create_table_compound_fk_executes_and_enforces() {
|
||||
parent_columns: Some(vec!["country".to_string(), "code".to_string()]),
|
||||
on_delete: ReferentialAction::NoAction,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
inline: false,
|
||||
}],
|
||||
false,
|
||||
None,
|
||||
@@ -363,6 +364,65 @@ fn compound_fk_arity_mismatch_is_refused() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_fk_referencing_compound_pk_points_at_table_level_form() {
|
||||
// ADR-0043 D4 residual: an *inline* single-column FK cannot express a
|
||||
// multi-column reference, so referencing a parent's compound PK must
|
||||
// refuse with a pointer to the table-level `FOREIGN KEY (...)` form —
|
||||
// not the generic arity message. The grammar marks the FK `inline`.
|
||||
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");
|
||||
|
||||
// Parse the inline form so the `inline` flag is set by the grammar.
|
||||
let cmd = parse_command(
|
||||
"create table City (country int references Region(country, code))",
|
||||
)
|
||||
.expect("parses");
|
||||
let Command::SqlCreateTable {
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
unique_constraints,
|
||||
check_constraints,
|
||||
foreign_keys,
|
||||
if_not_exists,
|
||||
} = cmd
|
||||
else {
|
||||
panic!("expected SqlCreateTable");
|
||||
};
|
||||
let err = db
|
||||
.sql_create_table(
|
||||
name,
|
||||
columns,
|
||||
primary_key,
|
||||
unique_constraints,
|
||||
check_constraints,
|
||||
foreign_keys,
|
||||
if_not_exists,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("inline FK referencing a compound PK must be refused");
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("FOREIGN KEY"),
|
||||
"expected a pointer to the table-level `FOREIGN KEY (...)` form, got: {msg}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_fk_type_mismatch_per_pair_is_refused() {
|
||||
let (_p, db, _dir) = open_project_db();
|
||||
|
||||
@@ -464,6 +464,81 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_fk_insert_compound_names_every_column_pair() {
|
||||
// ADR-0043 residual: a compound-FK violation must name *every*
|
||||
// child->parent column pair, not just the first. The single-column
|
||||
// facts slots carry the comma-joined lists.
|
||||
let db = db();
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Region".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("country".to_string(), Type::Int),
|
||||
ColumnSpec::new("code".to_string(), Type::Int),
|
||||
],
|
||||
vec!["country".to_string(), "code".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.create_table(
|
||||
"City".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("country".to_string(), Type::Int),
|
||||
ColumnSpec::new("region_code".to_string(), Type::Int),
|
||||
],
|
||||
vec![],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.add_relationship(
|
||||
None,
|
||||
"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
|
||||
.unwrap();
|
||||
|
||||
// Insert a City whose (country, region_code) has no parent Region.
|
||||
let cmd = Command::Insert {
|
||||
table: "City".to_string(),
|
||||
columns: Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||
values: vec![
|
||||
Value::Number("7".to_string()),
|
||||
Value::Number("8".to_string()),
|
||||
],
|
||||
};
|
||||
let err = db
|
||||
.insert(
|
||||
"City".to_string(),
|
||||
Some(vec!["country".to_string(), "region_code".to_string()]),
|
||||
vec![
|
||||
Value::Number("7".to_string()),
|
||||
Value::Number("8".to_string()),
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
let facts = enrich_dsl_failure(&db, &cmd, &err).await;
|
||||
assert_eq!(facts.table.as_deref(), Some("City"));
|
||||
assert_eq!(facts.parent_table.as_deref(), Some("Region"));
|
||||
// Both pairs named, not just the first.
|
||||
assert_eq!(facts.column.as_deref(), Some("country, region_code"));
|
||||
assert_eq!(facts.parent_column.as_deref(), Some("country, code"));
|
||||
assert_eq!(facts.value.as_deref(), Some("7, 8"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() {
|
||||
// Regression: `insert into Orders values (4, 11.99)` —
|
||||
|
||||
@@ -252,6 +252,91 @@ fn load_picker_renders_entries_and_navigates() {
|
||||
assert_eq!(source, "load");
|
||||
}
|
||||
|
||||
/// Build a load picker with three entries for the vi-navigation tests.
|
||||
fn three_entry_picker() -> App {
|
||||
let mut app = App::new();
|
||||
app.update(AppEvent::LoadPickerReady {
|
||||
entries: vec![
|
||||
LoadPickerEntry {
|
||||
display_name: "First".to_string(),
|
||||
modified: "2026-05-07 14:30".to_string(),
|
||||
path: std::path::PathBuf::from("/tmp/first"),
|
||||
is_temp: true,
|
||||
},
|
||||
LoadPickerEntry {
|
||||
display_name: "Second".to_string(),
|
||||
modified: "2026-05-05 10:00".to_string(),
|
||||
path: std::path::PathBuf::from("/tmp/second"),
|
||||
is_temp: false,
|
||||
},
|
||||
LoadPickerEntry {
|
||||
display_name: "Third".to_string(),
|
||||
modified: "2026-05-01 09:15".to_string(),
|
||||
path: std::path::PathBuf::from("/tmp/third"),
|
||||
is_temp: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
app
|
||||
}
|
||||
|
||||
fn picker_selected(app: &App) -> usize {
|
||||
let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else {
|
||||
panic!("expected LoadPicker modal");
|
||||
};
|
||||
picker.selected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_picker_jk_navigates_like_arrows() {
|
||||
// vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive
|
||||
// the load picker in documentation casts (#24).
|
||||
let mut app = three_entry_picker();
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
|
||||
// j moves the selection down.
|
||||
app.update(key(KeyCode::Char('j')));
|
||||
assert_eq!(picker_selected(&app), 1);
|
||||
app.update(key(KeyCode::Char('j')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// j at the last entry does not wrap past the end.
|
||||
app.update(key(KeyCode::Char('j')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// k moves the selection up.
|
||||
app.update(key(KeyCode::Char('k')));
|
||||
assert_eq!(picker_selected(&app), 1);
|
||||
|
||||
// k at the first entry does not wrap past the start.
|
||||
app.update(key(KeyCode::Char('k')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
app.update(key(KeyCode::Char('k')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_picker_g_jumps_to_first_and_last() {
|
||||
// g → first entry, G → last entry (vi convention).
|
||||
let mut app = three_entry_picker();
|
||||
|
||||
// G jumps to the last entry from the top.
|
||||
app.update(key(KeyCode::Char('G')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// G again is idempotent at the end.
|
||||
app.update(key(KeyCode::Char('G')));
|
||||
assert_eq!(picker_selected(&app), 2);
|
||||
|
||||
// g jumps back to the first entry.
|
||||
app.update(key(KeyCode::Char('g')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
|
||||
// g again is idempotent at the start.
|
||||
app.update(key(KeyCode::Char('g')));
|
||||
assert_eq!(picker_selected(&app), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_picker_b_enters_path_entry_submode() {
|
||||
let mut app = App::new();
|
||||
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
//! Integration tests for the m:n convenience command (C4 / ADR-0045):
|
||||
//! `create m:n relationship from <T1> to <T2> [as <name>]`.
|
||||
//!
|
||||
//! Covers parse, junction generation (columns / compound PK / two
|
||||
//! enforced FKs), the `as <name>` override, a compound-PK parent,
|
||||
//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less
|
||||
//! parent guard.
|
||||
|
||||
use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::command::RowFilter;
|
||||
use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{self, PLAYGROUND_DB};
|
||||
|
||||
fn rt() -> tokio::runtime::Runtime {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio rt")
|
||||
}
|
||||
|
||||
fn open() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("project");
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
.expect("db");
|
||||
(project, db, dir)
|
||||
}
|
||||
|
||||
fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = project::open_or_create(None, Some(dir.path())).expect("project");
|
||||
let db = Database::open_with_persistence_and_undo(
|
||||
project.db_path(),
|
||||
Persistence::new(project.path().to_path_buf()),
|
||||
true,
|
||||
)
|
||||
.expect("db");
|
||||
(project, db, dir)
|
||||
}
|
||||
|
||||
/// A parent table `(id serial PK, label text)` — the `label` gives an
|
||||
/// insertable non-PK column (a serial-PK-only table has nothing to put
|
||||
/// in a short-form INSERT).
|
||||
async fn serial_pk_table(db: &Database, name: &str) {
|
||||
db.create_table(
|
||||
name.to_string(),
|
||||
vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("create {name}: {e}"));
|
||||
}
|
||||
|
||||
/// Insert one row into a `serial_pk_table`, returning its auto-assigned id.
|
||||
async fn add_row(db: &Database, table: &str, label: &str) {
|
||||
db.insert(
|
||||
table.to_string(),
|
||||
Some(vec!["label".to_string()]),
|
||||
vec![Value::Text(label.to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("insert into {table}: {e}"));
|
||||
}
|
||||
|
||||
// ---- parse layer -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parses_to_create_m2n_relationship() {
|
||||
match parse_command("create m:n relationship from Students to Courses").expect("parses") {
|
||||
Command::CreateM2nRelationship { t1, t2, name } => {
|
||||
assert_eq!(t1, "Students");
|
||||
assert_eq!(t2, "Courses");
|
||||
assert_eq!(name, None);
|
||||
}
|
||||
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_with_as_name() {
|
||||
match parse_command("create m:n relationship from Students to Courses as Enrollments")
|
||||
.expect("parses")
|
||||
{
|
||||
Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")),
|
||||
other => panic!("expected CreateM2nRelationship, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- junction generation ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn generates_junction_with_compound_pk_and_two_enforced_fks() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect("create m:n");
|
||||
|
||||
// Auto-named `Students_Courses` exists.
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
|
||||
|
||||
// Two FK columns, both part of the compound PK.
|
||||
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
|
||||
let cols: Vec<(&str, bool)> =
|
||||
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
|
||||
assert_eq!(
|
||||
cols,
|
||||
vec![("Students_id", true), ("Courses_id", true)],
|
||||
"expected two FK columns forming the compound PK"
|
||||
);
|
||||
// Two outbound relationships (one per parent).
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs");
|
||||
|
||||
// FK enforcement: a junction row needs existing parents.
|
||||
add_row(&db, "Students", "s1").await;
|
||||
add_row(&db, "Courses", "c1").await;
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("valid link");
|
||||
// Duplicate link refused by the compound PK.
|
||||
let dup = db
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused");
|
||||
// A link to a non-existent parent is refused by the FK.
|
||||
let orphan = db
|
||||
.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("99".to_string())],
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(orphan.is_err(), "link to a non-existent Course must be refused");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_name_overrides_the_junction_table_name() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
Some("Enrollments".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create m:n as Enrollments");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_parent_pk_contributes_one_fk_column_each() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
// Sections has a 2-column PK (course_id, term).
|
||||
db.create_table(
|
||||
"Sections".to_string(),
|
||||
vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)],
|
||||
vec!["course_id".to_string(), "term".to_string()],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
serial_pk_table(&db, "Students").await;
|
||||
|
||||
db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None)
|
||||
.await
|
||||
.expect("create m:n");
|
||||
|
||||
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
|
||||
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
|
||||
// All three form the compound PK.
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_parent_cascades_to_the_junction() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
add_row(&db, "Students", "s1").await;
|
||||
add_row(&db, "Courses", "c1").await;
|
||||
db.insert(
|
||||
"Students_Courses".to_string(),
|
||||
Some(vec!["Students_id".to_string(), "Courses_id".to_string()]),
|
||||
vec![Value::Number("1".to_string()), Value::Number("1".to_string())],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Deleting the student cascades to the junction (ON DELETE CASCADE).
|
||||
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
|
||||
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
|
||||
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_is_one_undo_step() {
|
||||
let (_p, db, _d) = open_with_undo();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
// A real source makes the command undoable (a source-less call is
|
||||
// treated as an internal, non-undoable op).
|
||||
db.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
None,
|
||||
Some("create m:n relationship from Students to Courses".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string()));
|
||||
|
||||
// One undo removes the junction table AND both relationships.
|
||||
db.undo().await.unwrap();
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
|
||||
// The parents' relationships are gone too (the junction held them).
|
||||
let students = db.describe_table("Students".to_string(), None).await.unwrap();
|
||||
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- guards ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn self_referential_m2n_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Users").await;
|
||||
let err = db
|
||||
.create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("self m:n must be refused");
|
||||
assert!(format!("{err}").contains("two different tables"), "got: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_table_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a missing parent table must be refused");
|
||||
// The standard "no such table" guard (require_canonical_table).
|
||||
assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junction_name_collision_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect("first m:n");
|
||||
// A second identical m:n collides on the auto-name `Students_Courses`.
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a junction-name collision must be refused");
|
||||
assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the junction is a normal table ----------------------------
|
||||
|
||||
#[test]
|
||||
fn the_junction_can_be_renamed() {
|
||||
// C4 requirement text: "an auto-named junction table the user can
|
||||
// rename." It is a normal table, so `rename table` works.
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None)
|
||||
.await
|
||||
.expect("rename the junction");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
|
||||
assert!(!tables.contains(&"Students_Courses".to_string()));
|
||||
// Both relationships survive the rename (rebuild-preserving).
|
||||
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junction_survives_save_and_rebuild() {
|
||||
// Persistence round-trip: the junction + both relationships are
|
||||
// reconstructed from project.yaml after the .db is discarded.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project_path = {
|
||||
let project = project::open_or_create(None, Some(dir.path())).unwrap();
|
||||
let path = project.path().to_path_buf();
|
||||
let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
None,
|
||||
Some("create m:n relationship from Students to Courses".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
drop(db);
|
||||
drop(project);
|
||||
path
|
||||
};
|
||||
// Discard the derived .db so the next open rebuilds from text.
|
||||
std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap();
|
||||
let project = project::Project::open(&project_path).unwrap();
|
||||
let db =
|
||||
Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf()))
|
||||
.unwrap();
|
||||
rt().block_on(async {
|
||||
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
|
||||
let tables = db.list_tables().await.unwrap();
|
||||
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
|
||||
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
|
||||
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
|
||||
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_an_internal_name_is_refused() {
|
||||
// The junction must be a real, listable table — an `as __rdbms_*`
|
||||
// name would be filtered out of `list_tables` (a hidden orphan).
|
||||
// Guarded in the shared `do_create_table` (ADR-0045 /runda finding).
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
let err = db
|
||||
.create_m2n_relationship(
|
||||
"Students".to_string(),
|
||||
"Courses".to_string(),
|
||||
Some("__rdbms_evil".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("an internal junction name must be refused");
|
||||
assert!(format!("{err}").contains("no such table"), "got: {err}");
|
||||
assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pk_less_parent_is_refused() {
|
||||
let (_p, db, _d) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
// A PK-less table via the advanced SQL path.
|
||||
db.sql_create_table(
|
||||
"Loose".to_string(),
|
||||
vec![ColumnSpec::new("a", Type::Int)],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = db
|
||||
.create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None)
|
||||
.await
|
||||
.expect_err("a PK-less parent must be refused");
|
||||
assert!(format!("{err}").contains("no primary key"), "got: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
/// ADR-0046 DB2: the worker's `read_all_relationships` returns full
|
||||
/// schema records (name, parent/child tables + columns, actions) — the
|
||||
/// data source for the sidebar relationships panel. Exercised through
|
||||
/// the real worker thread after an m:n junction creates two of them.
|
||||
#[test]
|
||||
fn read_all_relationships_returns_the_junction_relationships() {
|
||||
let (_project, db, _dir) = open();
|
||||
rt().block_on(async {
|
||||
serial_pk_table(&db, "Students").await;
|
||||
serial_pk_table(&db, "Courses").await;
|
||||
db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None)
|
||||
.await
|
||||
.expect("create m:n");
|
||||
|
||||
let rels = db
|
||||
.read_all_relationships()
|
||||
.await
|
||||
.expect("read all relationships");
|
||||
assert_eq!(
|
||||
rels.len(),
|
||||
2,
|
||||
"the m:n junction creates two relationships: {rels:?}"
|
||||
);
|
||||
// Both have the junction (Students_Courses) as their child.
|
||||
for r in &rels {
|
||||
assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}");
|
||||
}
|
||||
// One points back to each parent.
|
||||
let parents: std::collections::BTreeSet<&str> =
|
||||
rels.iter().map(|r| r.parent_table.as_str()).collect();
|
||||
assert!(
|
||||
parents.contains("Students") && parents.contains("Courses"),
|
||||
"one relationship per parent: {rels:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod iteration4a_rebuild_command;
|
||||
mod iteration4b_lifecycle_commands;
|
||||
mod iteration5_export_import;
|
||||
mod iteration6_resume_history;
|
||||
mod m2n;
|
||||
mod parse_error_pedagogy;
|
||||
mod project_lifecycle;
|
||||
mod replay_command;
|
||||
|
||||
@@ -65,6 +65,50 @@ fn replay_is_refused(script: &str) -> bool {
|
||||
matches!(events.last(), Some(AppEvent::ReplayFailed { .. }))
|
||||
}
|
||||
|
||||
/// Like [`replay_is_refused`] but returns the failure message, so a test
|
||||
/// can assert the command was refused *for the expected reason* rather
|
||||
/// than e.g. a parse error.
|
||||
fn replay_failure_message(script: &str) -> Option<String> {
|
||||
let (project, db, _d) = open();
|
||||
let r = rt();
|
||||
std::fs::write(project.path().join("conv.commands"), script).expect("write script");
|
||||
let events = r.block_on(run_replay(&db, project.path(), "conv.commands"));
|
||||
match events.last() {
|
||||
Some(AppEvent::ReplayFailed { error, .. }) => Some(error.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_alter_drop_primary_key_column_is_refused() {
|
||||
// Issue #19: dropping a PK column must be refused on the advanced
|
||||
// ALTER surface too (it reaches the shared `do_drop_column` guard).
|
||||
let msg = replay_failure_message(
|
||||
"create table T (id int primary key, v text)\n\
|
||||
alter table T drop column id\n",
|
||||
)
|
||||
.expect("dropping a PK column must be refused");
|
||||
assert!(
|
||||
msg.to_lowercase().contains("primary"),
|
||||
"refused for the wrong reason: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_alter_drop_compound_primary_key_member_is_refused() {
|
||||
// A member of a *compound* PK is still a PK column, so dropping it is
|
||||
// refused identically (each member reports primary_key = true).
|
||||
let msg = replay_failure_message(
|
||||
"create table T (a int, b int, v text, primary key (a, b))\n\
|
||||
alter table T drop column a\n",
|
||||
)
|
||||
.expect("dropping a compound-PK member must be refused");
|
||||
assert!(
|
||||
msg.to_lowercase().contains("primary"),
|
||||
"refused for the wrong reason: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The current user-facing type of column `name` in table `T`.
|
||||
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
|
||||
r.block_on(db.describe_table("T".to_string(), None))
|
||||
|
||||
@@ -839,6 +839,7 @@ fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> Sq
|
||||
parent_columns: parent_column.map(|c| vec![c.to_string()]),
|
||||
on_delete: ReferentialAction::NoAction,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
inline: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ fn dropping_a_referenced_parent_is_refused() {
|
||||
parent_columns: Some(vec!["id".to_string()]),
|
||||
on_delete: rdbms_playground::dsl::ReferentialAction::NoAction,
|
||||
on_update: rdbms_playground::dsl::ReferentialAction::NoAction,
|
||||
inline: true,
|
||||
}],
|
||||
false,
|
||||
Some("create table child (id serial primary key, pid int references parent(id))".to_string()),
|
||||
|
||||
@@ -301,7 +301,8 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
||||
assert_eq!(app.tables, vec!["Customers".to_string()]);
|
||||
assert_eq!(app.current_table, Some(desc));
|
||||
|
||||
let rendered = rendered_text(&mut app, &theme, 80, 24);
|
||||
// Width > 90 so the sidebar (items panel) is shown (ADR-0046 DB1).
|
||||
let rendered = rendered_text(&mut app, &theme, 110, 24);
|
||||
assert!(
|
||||
rendered.contains("Customers"),
|
||||
"items panel should list Customers:\n{rendered}"
|
||||
@@ -397,7 +398,8 @@ fn drop_table_flow_clears_items_list() {
|
||||
|
||||
assert!(app.tables.is_empty());
|
||||
assert!(app.current_table.is_none());
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
// Width > 90 so the (now-empty) sidebar is shown (ADR-0046 DB1).
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 110, 24);
|
||||
assert!(rendered.contains("(none yet)"));
|
||||
// ADR-0040: `drop table` is content-less, so the echo's ✓ marker
|
||||
// is the entire success signal (replacing `[ok] drop table …`).
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Matrix coverage for `create m:n relationship from <T1> to <T2>
|
||||
//! [as <name>]` (C4 / ADR-0045). Exercises the full typing surface —
|
||||
//! completion candidates, ambient hint, highlighting, and parse state —
|
||||
//! at each stage, so a regression in any of those surfaces is caught.
|
||||
|
||||
use crate::typing_surface::*;
|
||||
use rdbms_playground::input_render::InputState;
|
||||
|
||||
#[test]
|
||||
fn after_create_offers_table_and_m2n() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
// `create` branches to `table` (create table) or the `m:n` composite.
|
||||
assert_candidate_present(&a, &["table", "m:n"]);
|
||||
crate::snap!("after_create", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m2n_relationship_keyword_sequence_is_incomplete() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["from"]);
|
||||
crate::snap!("after_relationship_keyword", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_from_offers_table_names() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("after_from", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_to_offers_table_names() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from Customers to ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("after_to", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_create_m2n_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from Customers to Orders", &schema);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
|
||||
crate::snap!("complete", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_m2n_with_as_name_parses() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"create m:n relationship from Customers to Orders as CustomerOrders",
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship"));
|
||||
crate::snap!("with_as_name", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_as_keyword_is_incomplete() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("after_as", a);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ pub mod create_table;
|
||||
pub mod drop_column;
|
||||
pub mod drop_relationship;
|
||||
pub mod add_relationship;
|
||||
pub mod create_m2n;
|
||||
pub mod index_ops;
|
||||
pub mod constraints;
|
||||
pub mod rename_change_column;
|
||||
@@ -224,6 +225,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
||||
RenameColumn { .. } => "RenameColumn".into(),
|
||||
ChangeColumnType { .. } => "ChangeColumnType".into(),
|
||||
AddRelationship { .. } => "AddRelationship".into(),
|
||||
CreateM2nRelationship { .. } => "CreateM2nRelationship".into(),
|
||||
DropRelationship { .. } => "DropRelationship".into(),
|
||||
AddIndex { .. } => "AddIndex".into(),
|
||||
DropIndex { .. } => "DropIndex".into(),
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tests/typing_surface/create_m2n.rs
|
||||
assertion_line: 72
|
||||
description: "input=\"create m:n relationship from Customers to Orders as \" cursor=52"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "create m:n relationship from Customers to Orders as ",
|
||||
cursor: 52,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Type a name",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
---
|
||||
source: tests/typing_surface/create_m2n.rs
|
||||
assertion_line: 16
|
||||
description: "input=\"create \" cursor=7"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "create ",
|
||||
cursor: 7,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "table",
|
||||
kind: Keyword,
|
||||
mode: Simple,
|
||||
},
|
||||
Candidate {
|
||||
text: "m:n",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
7,
|
||||
7,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "table",
|
||||
kind: Keyword,
|
||||
mode: Simple,
|
||||
},
|
||||
Candidate {
|
||||
text: "m:n",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
---
|
||||
source: tests/typing_surface/create_m2n.rs
|
||||
assertion_line: 34
|
||||
description: "input=\"create m:n relationship from \" cursor=29"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "create m:n relationship from ",
|
||||
cursor: 29,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
29,
|
||||
29,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
---
|
||||
source: tests/typing_surface/create_m2n.rs
|
||||
assertion_line: 43
|
||||
description: "input=\"create m:n relationship from Customers to \" cursor=42"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "create m:n relationship from Customers to ",
|
||||
cursor: 42,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
42,
|
||||
42,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tests/typing_surface/create_m2n.rs
|
||||
assertion_line: 52
|
||||
description: "input=\"create m:n relationship from Customers to Orders\" cursor=48"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "create m:n relationship from Customers to Orders",
|
||||
cursor: 48,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Submit with Enter",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"CreateM2nRelationship",
|
||||
),
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tests/typing_surface/create_m2n.rs
|
||||
assertion_line: 64
|
||||
description: "input=\"create m:n relationship from Customers to Orders as CustomerOrders\" cursor=66"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "create m:n relationship from Customers to Orders as CustomerOrders",
|
||||
cursor: 66,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Type a name",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"CreateM2nRelationship",
|
||||
),
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
---
|
||||
source: tests/typing_surface/create_m2n.rs
|
||||
assertion_line: 25
|
||||
description: "input=\"create m:n relationship \" cursor=24"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "create m:n relationship ",
|
||||
cursor: 24,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "from",
|
||||
kind: Keyword,
|
||||
mode: Simple,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
24,
|
||||
24,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "from",
|
||||
kind: Keyword,
|
||||
mode: Simple,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+11
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tests/typing_surface/create_table.rs
|
||||
assertion_line: 13
|
||||
description: "input=\"create \" cursor=7"
|
||||
expression: "& a"
|
||||
---
|
||||
@@ -13,6 +14,11 @@ Assessment {
|
||||
Candidate {
|
||||
text: "table",
|
||||
kind: Keyword,
|
||||
mode: Simple,
|
||||
},
|
||||
Candidate {
|
||||
text: "m:n",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
@@ -30,6 +36,11 @@ Assessment {
|
||||
Candidate {
|
||||
text: "table",
|
||||
kind: Keyword,
|
||||
mode: Simple,
|
||||
},
|
||||
Candidate {
|
||||
text: "m:n",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
},
|
||||
],
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tests/typing_surface/create_table.rs
|
||||
assertion_line: 48
|
||||
description: "input=\"create table Customers with \" cursor=28"
|
||||
expression: "& a"
|
||||
---
|
||||
@@ -13,7 +14,7 @@ Assessment {
|
||||
Candidate {
|
||||
text: "pk",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
mode: Simple,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
@@ -30,7 +31,7 @@ Assessment {
|
||||
Candidate {
|
||||
text: "pk",
|
||||
kind: Keyword,
|
||||
mode: Both,
|
||||
mode: Simple,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user