Foreign-key relationships, rebuild-table, polish round
DSL:
- add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
[on delete <action>] [on update <action>] [--create-fk]
- drop relationship <name> | from <P>.<col> to <C>.<col>
- show table <name> for re-displaying a structure on demand
Database (ADR-0013):
- Rebuild-table primitive following SQLite's
ALTER-via-rebuild recipe (foreign_keys=OFF outside tx,
copy-by-name, foreign_key_check before commit). Reusable for
B2 (column drops/renames/type changes).
- ReferentialAction enum (no action / restrict / set null /
cascade); SET DEFAULT awaits column DEFAULTs.
- __rdbms_playground_relationships metadata table -- names,
auto-generated as <Parent>_<pcol>_to_<Child>_<ccol>.
- Type::fk_target_type() validation at declaration; friendly
errors for type mismatch, non-PK target, missing column,
duplicate name.
- describe_table populates symmetric outbound + inbound
relationship lists. drop_table refuses while inbound
references exist; outbound metadata cleaned up alongside drop.
App / UI:
- In-line cursor editing in the input field: Left, Right,
Home, End, Delete, Backspace honoring UTF-8 boundaries.
- PageUp / PageDown scrolls the output buffer; viewport row
count fed back from the renderer via App::note_output_viewport
so scroll is capped against the actual visible area
(regression-tested) and snaps to the bottom on new output.
- Failure messages quote the command portion ("verb target"
failed: ...) for visual clarity; RelationshipSelector has a
proper Display impl so "no such relationship" reads cleanly.
- Structure rendering shows References / Referenced by sections.
Docs:
- ADR-0013 covers naming, metadata table, symmetric view, and
the rebuild-table strategy.
- requirements.md updates: C3 (FK done), B2 (primitive in),
T3 (compound-PK FK still pending). New entries: I1a (cursor
editing -- landed), I1b (Ctrl-A/E and readline shortcuts --
pending), V4 partial scroll, V5 (show family), C3a (modify
relationship -- deferred).
Tests: 154 passing (140 lib + 14 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
@@ -0,0 +1,234 @@
|
|||||||
|
# ADR-0013: Relationships, naming, and the rebuild-table strategy
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This iteration introduces foreign-key relationships between
|
||||||
|
tables (C3 partial), which exposes two coupled design problems:
|
||||||
|
|
||||||
|
1. **SQLite's `ALTER TABLE` cannot add or drop a `FOREIGN KEY`
|
||||||
|
constraint** on an existing table. The accepted SQLite recipe
|
||||||
|
for this is the rebuild-table dance (create a new table with
|
||||||
|
the desired shape, copy the data, drop the old, rename). This
|
||||||
|
same machinery is what B2 (column drops/renames/type changes)
|
||||||
|
needs in future iterations, so the cost is shared.
|
||||||
|
2. **SQL foreign-key constraints have no user-name slot.** A
|
||||||
|
relationship is identified internally by its `(child, column,
|
||||||
|
parent, column)` quadruple. Pedagogically, the user's mental
|
||||||
|
model is more natural with named relationships ("the
|
||||||
|
`cust_orders` relationship") that survive renaming and feature
|
||||||
|
in drop / future modify commands. A name field has to come
|
||||||
|
from somewhere we own.
|
||||||
|
|
||||||
|
Additionally, the SQLite schema model treats a foreign key as a
|
||||||
|
property of the *child* table only. A learner viewing the parent
|
||||||
|
table normally has no clue that other tables refer to it. This
|
||||||
|
asymmetry runs counter to the relational mental model.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Grammar
|
||||||
|
|
||||||
|
Relationships are declared and removed via DSL commands that
|
||||||
|
follow ADR-0009 (required clauses keyword-based; `--` flags for
|
||||||
|
opt-ins):
|
||||||
|
|
||||||
|
```
|
||||||
|
add 1:n relationship [as <name>] from <Parent>.<col> to <Child>.<col>
|
||||||
|
[on delete <action>]
|
||||||
|
[on update <action>]
|
||||||
|
[--create-fk]
|
||||||
|
|
||||||
|
drop relationship <name>
|
||||||
|
drop relationship from <Parent>.<col> to <Child>.<col>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `as <name>` is optional. The keyword `as` is required to
|
||||||
|
introduce the name so the parser is unambiguous in the
|
||||||
|
presence of `from` (an unkeyed identifier could otherwise be
|
||||||
|
confused with the parent table identifier).
|
||||||
|
- `on delete` / `on update` clauses are independently optional
|
||||||
|
and can appear in either order; default action is `no action`.
|
||||||
|
- `--create-fk` auto-creates the child column with the
|
||||||
|
appropriate type (`Type::fk_target_type()` of the parent
|
||||||
|
column, per ADR-0011) when it does not yet exist. Without the
|
||||||
|
flag, missing child column is a friendly error advising the
|
||||||
|
user to add the column first or use the flag.
|
||||||
|
- Drop accepts both forms — the named form for users who picked
|
||||||
|
a name; the positional form for users who let it auto-generate.
|
||||||
|
|
||||||
|
### Auto-name format
|
||||||
|
|
||||||
|
When `as <name>` is omitted, the executor generates
|
||||||
|
`<Parent>_<parent_col>_to_<Child>_<child_col>`, matching the
|
||||||
|
direction of the user's `from <Parent>.<col> to <Child>.<col>`
|
||||||
|
syntax. Example: a relationship from `Customers.id` to
|
||||||
|
`Orders.CustId` is named `Customers_id_to_Orders_CustId`. Long
|
||||||
|
but unambiguous, and reads in the same direction as the
|
||||||
|
declaration.
|
||||||
|
|
||||||
|
### Internal metadata
|
||||||
|
|
||||||
|
A new internal table tracks relationship metadata:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE __rdbms_playground_relationships (
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
parent_table TEXT NOT NULL,
|
||||||
|
parent_column TEXT NOT NULL,
|
||||||
|
child_table TEXT NOT NULL,
|
||||||
|
child_column TEXT NOT NULL,
|
||||||
|
on_delete TEXT NOT NULL,
|
||||||
|
on_update TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (child_table, child_column)
|
||||||
|
) STRICT;
|
||||||
|
```
|
||||||
|
|
||||||
|
The PK on `(child_table, child_column)` enforces our convention
|
||||||
|
"at most one FK per child column" — true in typical SQL practice
|
||||||
|
even though SQLite would technically allow more. The unique
|
||||||
|
`name` ensures no two relationships collide in identification.
|
||||||
|
|
||||||
|
Created at connection open alongside `__rdbms_playground_columns`
|
||||||
|
(ADR-0012); follows the `__rdbms_*` internal-table naming
|
||||||
|
convention. `list_tables` filters this out of user-visible
|
||||||
|
listings.
|
||||||
|
|
||||||
|
### Symmetric description
|
||||||
|
|
||||||
|
`describe_table` populates two collections on `TableDescription`:
|
||||||
|
|
||||||
|
- `outbound_relationships` — relationships where this table is
|
||||||
|
the *child* (it has the FK column).
|
||||||
|
- `inbound_relationships` — relationships where this table is
|
||||||
|
the *parent* (other tables reference one of its PK columns).
|
||||||
|
|
||||||
|
Both are populated by querying the relationships metadata table
|
||||||
|
(child-side for outbound; parent-side for inbound), which keeps
|
||||||
|
the symmetric view consistent with the underlying constraints
|
||||||
|
that SQLite enforces.
|
||||||
|
|
||||||
|
The structure view in the output panel renders both sections
|
||||||
|
when present:
|
||||||
|
|
||||||
|
```
|
||||||
|
Customers
|
||||||
|
Id [serial PK]
|
||||||
|
Email [text]
|
||||||
|
Referenced by:
|
||||||
|
Orders.CustId → Id (Customers_id_to_Orders_CustId, on delete cascade, on update no action)
|
||||||
|
|
||||||
|
Orders
|
||||||
|
Id [serial PK]
|
||||||
|
CustId [int]
|
||||||
|
References:
|
||||||
|
CustId → Customers.Id (Customers_id_to_Orders_CustId, on delete cascade, on update no action)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type compatibility check
|
||||||
|
|
||||||
|
Per ADR-0011, the FK column type must match the parent column's
|
||||||
|
`fk_target_type()`. The executor checks this:
|
||||||
|
|
||||||
|
- For `--create-fk`: the column is created with that type, no
|
||||||
|
user-side decision required.
|
||||||
|
- For an existing column: type is compared. Mismatch yields a
|
||||||
|
friendly error naming both types and the expected one. The
|
||||||
|
user learns through the error rather than via a silent
|
||||||
|
promotion.
|
||||||
|
|
||||||
|
The non-PK-target case (FK referencing a non-PK column) is
|
||||||
|
rejected with a clear error. UNIQUE-target FKs land when
|
||||||
|
UNIQUE constraints (C3 partial) do.
|
||||||
|
|
||||||
|
### Rebuild-table dance
|
||||||
|
|
||||||
|
The `rebuild_table` primitive performs the SQLite-recommended
|
||||||
|
ALTER-via-rebuild sequence:
|
||||||
|
|
||||||
|
```
|
||||||
|
PRAGMA foreign_keys = OFF; -- must be outside any tx
|
||||||
|
BEGIN;
|
||||||
|
DROP TABLE IF EXISTS __rdbms_rebuild_<name>;
|
||||||
|
CREATE TABLE __rdbms_rebuild_<name> (...) STRICT;
|
||||||
|
INSERT INTO __rdbms_rebuild_<name> (cols-on-both)
|
||||||
|
SELECT (cols-on-both) FROM <name>;
|
||||||
|
DROP TABLE <name>;
|
||||||
|
ALTER TABLE __rdbms_rebuild_<name> RENAME TO <name>;
|
||||||
|
<metadata updates>
|
||||||
|
PRAGMA foreign_key_check; -- abort on any returned rows
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
```
|
||||||
|
|
||||||
|
Key properties:
|
||||||
|
|
||||||
|
- `foreign_keys` is toggled at the connection level outside the
|
||||||
|
transaction, because the pragma is a no-op inside one.
|
||||||
|
- The `IF EXISTS` clause defends against a leftover rebuild
|
||||||
|
table from a previous failed attempt.
|
||||||
|
- Data is copied **column-by-name** — only columns present on
|
||||||
|
both old and new schemas are transferred. Auto-created columns
|
||||||
|
(e.g. via `--create-fk`) start as `NULL` in existing rows.
|
||||||
|
- `foreign_key_check` runs before commit. Any returned rows mean
|
||||||
|
existing data violates the new constraint, which we surface as
|
||||||
|
a clear error and roll back.
|
||||||
|
- The metadata-updates closure runs inside the same transaction
|
||||||
|
so the schema and the metadata stay consistent.
|
||||||
|
|
||||||
|
This primitive is reused by `add_relationship` and
|
||||||
|
`drop_relationship`. Future B2 work (drop column, rename column,
|
||||||
|
change column type) plugs into the same primitive without
|
||||||
|
re-implementing the dance.
|
||||||
|
|
||||||
|
### Drop-table interaction
|
||||||
|
|
||||||
|
A `drop table` request is rejected when the table has *inbound*
|
||||||
|
relationships — dropping it would leave dangling FK constraints
|
||||||
|
in the children. The error names the offending relationships;
|
||||||
|
the user drops those first, then drops the table.
|
||||||
|
|
||||||
|
Outbound relationships are removed naturally with the table; the
|
||||||
|
metadata rows are cleaned up alongside the drop in the same
|
||||||
|
transaction.
|
||||||
|
|
||||||
|
### Modify deferred
|
||||||
|
|
||||||
|
`modify relationship <name> [on delete <action>] [on update
|
||||||
|
<action>]` would be a one-step way to change actions without
|
||||||
|
typing both the drop and the add. The mechanics reuse the
|
||||||
|
rebuild dance. **Deferred** to a follow-up iteration; users can
|
||||||
|
achieve the same via drop + add today.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Pedagogically honest: the user creates tables, adds columns,
|
||||||
|
declares relationships in the natural order. Type mismatches
|
||||||
|
and missing columns are caught at declaration time with
|
||||||
|
actionable errors.
|
||||||
|
- The rebuild-table primitive is the load-bearing piece for B2
|
||||||
|
too; future column drops, renames, and type changes layer on
|
||||||
|
top.
|
||||||
|
- The relationship metadata table is a new authority alongside
|
||||||
|
the column metadata table (ADR-0012). Both are owned by the
|
||||||
|
app and round-trip through the project file format (track 2)
|
||||||
|
when that lands.
|
||||||
|
- Without I/U/D (C5) the FK constraints are observable but not
|
||||||
|
yet *demonstrable* (you can't try inserting a violating row).
|
||||||
|
C5 is the obvious follow-up.
|
||||||
|
- The `as <name>` keyword is established. Future commands that
|
||||||
|
accept optional names should follow the same convention to
|
||||||
|
stay parser-unambiguous.
|
||||||
|
- `drop table` becomes more conservative — it now refuses when
|
||||||
|
inbound relationships exist. This is a learnability win
|
||||||
|
(failure has a clear cause) and matches relational expectations.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- ADR-0009 (DSL command syntax conventions)
|
||||||
|
- ADR-0010 (DB worker thread)
|
||||||
|
- ADR-0011 (FK column type compatibility)
|
||||||
|
- ADR-0012 (Internal column metadata)
|
||||||
@@ -18,3 +18,4 @@ This directory contains the project's ADRs, recorded per
|
|||||||
- [ADR-0010 — Database access via a dedicated worker thread](0010-database-access-via-worker-thread.md)
|
- [ADR-0010 — Database access via a dedicated worker thread](0010-database-access-via-worker-thread.md)
|
||||||
- [ADR-0011 — Foreign-key column type compatibility](0011-fk-column-type-compatibility.md)
|
- [ADR-0011 — Foreign-key column type compatibility](0011-fk-column-type-compatibility.md)
|
||||||
- [ADR-0012 — Internal metadata for user-facing column types](0012-internal-metadata-for-user-facing-types.md)
|
- [ADR-0012 — Internal metadata for user-facing column types](0012-internal-metadata-for-user-facing-types.md)
|
||||||
|
- [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md)
|
||||||
|
|||||||
+36
-3
@@ -61,6 +61,17 @@ against it.
|
|||||||
|
|
||||||
- [ ] **I1** Multi-line entry that auto-expands; Ctrl-Enter (or
|
- [ ] **I1** Multi-line entry that auto-expands; Ctrl-Enter (or
|
||||||
equivalent) submits, plain Enter inserts a newline.
|
equivalent) submits, plain Enter inserts a newline.
|
||||||
|
- [ ] **I1a** In-line cursor editing in the input field: Left /
|
||||||
|
Right arrows move the cursor by character (UTF-8 boundaries
|
||||||
|
honoured), Home / End jump to the extremes, Delete removes the
|
||||||
|
character at the cursor, Backspace removes the character
|
||||||
|
before. Insertion happens at the cursor position. *(Implemented;
|
||||||
|
multi-line editing per I1 still pending.)*
|
||||||
|
- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
||||||
|
as aliases for Home / End for users on keyboards without those
|
||||||
|
keys (and for ergonomics in command-driven workflows). Likely
|
||||||
|
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
|
||||||
|
end), Ctrl-U (delete to start). Pending.
|
||||||
- [ ] **I2** Persistent navigable input history (project-scoped,
|
- [ ] **I2** Persistent navigable input history (project-scoped,
|
||||||
with a global rolling history also available).
|
with a global rolling history also available).
|
||||||
*(Progress: in-memory navigable history (Up/Down arrows, draft
|
*(Progress: in-memory navigable history (Up/Down arrows, draft
|
||||||
@@ -108,7 +119,16 @@ against it.
|
|||||||
compound), foreign key with `ON DELETE` / `ON UPDATE` referential
|
compound), foreign key with `ON DELETE` / `ON UPDATE` referential
|
||||||
actions, indexes, `NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT`.
|
actions, indexes, `NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT`.
|
||||||
*(Progress: PK including compound done at create-table time;
|
*(Progress: PK including compound done at create-table time;
|
||||||
FK/index/NOT NULL/UNIQUE/CHECK/DEFAULT pending.)*
|
FK with `ON DELETE` / `ON UPDATE` actions done (ADR-0013) —
|
||||||
|
declared via `add 1:n relationship`; symmetric outbound +
|
||||||
|
inbound view in the structure renderer; type compatibility
|
||||||
|
validated at declaration via `Type::fk_target_type()`. Index,
|
||||||
|
`NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT` still pending.)*
|
||||||
|
- [~] **C3a** Modify relationship: `modify relationship <name>
|
||||||
|
[on delete <action>] [on update <action>]`. Users can achieve
|
||||||
|
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
|
- [ ] **C4** Convenience: `create m:n relationship from <T1> to
|
||||||
<T2>` produces an auto-named junction table the user can rename;
|
<T2>` produces an auto-named junction table the user can rename;
|
||||||
pulls primary keys and FK definitions automatically.
|
pulls primary keys and FK definitions automatically.
|
||||||
@@ -135,6 +155,10 @@ against it.
|
|||||||
through a dedicated worker thread per ADR-0010.)*
|
through a dedicated worker thread per ADR-0010.)*
|
||||||
- [ ] **B2** Schema evolution uses the rebuild-table technique
|
- [ ] **B2** Schema evolution uses the rebuild-table technique
|
||||||
internally where SQLite `ALTER TABLE` cannot.
|
internally where SQLite `ALTER TABLE` cannot.
|
||||||
|
*(Progress: rebuild-table primitive landed (ADR-0013) and is
|
||||||
|
used by `add_relationship` / `drop_relationship`. Reuse for
|
||||||
|
column drops/renames/type changes pending; the primitive is
|
||||||
|
designed to support those without further architectural work.)*
|
||||||
- [ ] **B3** Query timeout and cancellation supported (no
|
- [ ] **B3** Query timeout and cancellation supported (no
|
||||||
cartesian-join-of-doom can hang the app).
|
cartesian-join-of-doom can hang the app).
|
||||||
*(Progress: the worker-thread architecture is in place; the
|
*(Progress: the worker-thread architecture is in place; the
|
||||||
@@ -153,8 +177,9 @@ against it.
|
|||||||
- [ ] **T3** Compound primary keys handled end-to-end (DSL,
|
- [ ] **T3** Compound primary keys handled end-to-end (DSL,
|
||||||
storage, display, FK reference).
|
storage, display, FK reference).
|
||||||
*(Progress: DSL grammar (`with pk a:int,b:int`), storage, and
|
*(Progress: DSL grammar (`with pk a:int,b:int`), storage, and
|
||||||
table-info description are all present; pretty display of the
|
table-info description are all present; the FK iteration
|
||||||
PK in the structure view and FK reference still pending.)*
|
references single-column PKs only — compound-key FK references
|
||||||
|
remain pending.)*
|
||||||
|
|
||||||
## Visualizations
|
## Visualizations
|
||||||
|
|
||||||
@@ -179,6 +204,14 @@ against it.
|
|||||||
based on dimensions; the log is exportable to Markdown so
|
based on dimensions; the log is exportable to Markdown so
|
||||||
learners can keep a record of their session. Design and ADR
|
learners can keep a record of their session. Design and ADR
|
||||||
pending before any implementation.
|
pending before any implementation.
|
||||||
|
*(Partial: PageUp / PageDown scrolling of the existing line
|
||||||
|
buffer is in, with new output snapping the view to the most
|
||||||
|
recent. The full V4 scope — smart structure rendering, log
|
||||||
|
styling, Markdown export, scroll indicator — remains pending.)*
|
||||||
|
- [ ] **V5** `show <kind> [<name>]` family of commands for
|
||||||
|
redisplaying schema info on demand. *(Progress: `show table
|
||||||
|
<name>` implemented and reuses the structure-render pipeline;
|
||||||
|
`show tables`, `show relationships`, etc. pending.)*
|
||||||
|
|
||||||
## Project lifecycle (per ADR-0004)
|
## Project lifecycle (per ADR-0004)
|
||||||
|
|
||||||
|
|||||||
+409
-4
@@ -59,6 +59,9 @@ impl EffectiveMode {
|
|||||||
pub struct App {
|
pub struct App {
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub input: String,
|
pub input: String,
|
||||||
|
/// Byte offset into `input` where the next character will be
|
||||||
|
/// inserted. Always lies on a UTF-8 character boundary.
|
||||||
|
pub input_cursor: usize,
|
||||||
pub output: VecDeque<OutputLine>,
|
pub output: VecDeque<OutputLine>,
|
||||||
pub hint: Option<String>,
|
pub hint: Option<String>,
|
||||||
pub tables: Vec<String>,
|
pub tables: Vec<String>,
|
||||||
@@ -77,8 +80,24 @@ pub struct App {
|
|||||||
/// start navigating history, restored if they navigate back
|
/// start navigating history, restored if they navigate back
|
||||||
/// past the most recent entry.
|
/// past the most recent entry.
|
||||||
history_draft: Option<String>,
|
history_draft: Option<String>,
|
||||||
|
/// Number of lines from the bottom we've scrolled up. `0`
|
||||||
|
/// means "showing the most recent lines"; positive values
|
||||||
|
/// reveal older lines. Reset to `0` whenever a new output
|
||||||
|
/// line is appended so newly-arrived results are always
|
||||||
|
/// visible after a command. The full V4 session-log spec
|
||||||
|
/// supersedes this; we ship a minimal subset now to address
|
||||||
|
/// the immediate "ran out of space" UX problem.
|
||||||
|
pub output_scroll: usize,
|
||||||
|
/// The most recent visible-row count of the output panel,
|
||||||
|
/// reported by the renderer. Used to cap `output_scroll` —
|
||||||
|
/// without this, scrolling past `len - visible` would slide
|
||||||
|
/// the visible window off the top of the buffer and shrink
|
||||||
|
/// what the user sees.
|
||||||
|
pub last_output_visible: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SCROLL_LINES: usize = 5;
|
||||||
|
|
||||||
const HISTORY_CAPACITY: usize = 1000;
|
const HISTORY_CAPACITY: usize = 1000;
|
||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
@@ -93,6 +112,7 @@ impl App {
|
|||||||
Self {
|
Self {
|
||||||
mode: Mode::Simple,
|
mode: Mode::Simple,
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
|
input_cursor: 0,
|
||||||
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
||||||
hint: None,
|
hint: None,
|
||||||
tables: Vec::new(),
|
tables: Vec::new(),
|
||||||
@@ -100,6 +120,22 @@ impl App {
|
|||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
history_cursor: None,
|
history_cursor: None,
|
||||||
history_draft: None,
|
history_draft: None,
|
||||||
|
output_scroll: 0,
|
||||||
|
last_output_visible: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the renderer with the current output-panel row
|
||||||
|
/// count so subsequent scroll input is capped against the
|
||||||
|
/// actual visible area, not the unrelated buffer length.
|
||||||
|
pub fn note_output_viewport(&mut self, visible_rows: usize) {
|
||||||
|
self.last_output_visible = visible_rows;
|
||||||
|
// If a previous PageUp drifted past the maximum useful
|
||||||
|
// scroll (e.g. the user kept paging up past the top),
|
||||||
|
// bring it back so the next PageDown is responsive.
|
||||||
|
let max = self.output.len().saturating_sub(visible_rows);
|
||||||
|
if self.output_scroll > max {
|
||||||
|
self.output_scroll = max;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,21 +197,50 @@ impl App {
|
|||||||
self.history_forward();
|
self.history_forward();
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
(KeyCode::Left, _) => {
|
||||||
|
self.cursor_left();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::Right, _) => {
|
||||||
|
self.cursor_right();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::Home, _) => {
|
||||||
|
self.input_cursor = 0;
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::End, _) => {
|
||||||
|
self.input_cursor = self.input.len();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
(KeyCode::Backspace, _) => {
|
(KeyCode::Backspace, _) => {
|
||||||
self.cancel_history_navigation();
|
self.cancel_history_navigation();
|
||||||
self.input.pop();
|
self.delete_before_cursor();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::Delete, _) => {
|
||||||
|
self.cancel_history_navigation();
|
||||||
|
self.delete_at_cursor();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::PageUp, _) => {
|
||||||
|
self.scroll_output_up();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::PageDown, _) => {
|
||||||
|
self.scroll_output_down();
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
|
||||||
self.cancel_history_navigation();
|
self.cancel_history_navigation();
|
||||||
let was_empty = self.input.is_empty();
|
let was_empty = self.input.is_empty();
|
||||||
self.input.push(c);
|
self.insert_at_cursor(c);
|
||||||
// Convenience: when `:` becomes the leading character in
|
// Convenience: when `:` becomes the leading character in
|
||||||
// simple mode, auto-insert a space after it so the input
|
// simple mode, auto-insert a space after it so the input
|
||||||
// reads ": foo" rather than ":foo". The trailing space is
|
// reads ": foo" rather than ":foo". The trailing space is
|
||||||
// an ordinary character — backspace removes it normally.
|
// an ordinary character — backspace removes it normally.
|
||||||
if c == ':' && was_empty && self.mode == Mode::Simple {
|
if c == ':' && was_empty && self.mode == Mode::Simple {
|
||||||
self.input.push(' ');
|
self.insert_at_cursor(' ');
|
||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -183,6 +248,65 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cursor_left(&mut self) {
|
||||||
|
let mut idx = self.input_cursor;
|
||||||
|
while idx > 0 {
|
||||||
|
idx -= 1;
|
||||||
|
if self.input.is_char_boundary(idx) {
|
||||||
|
self.input_cursor = idx;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.input_cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_right(&mut self) {
|
||||||
|
let mut idx = self.input_cursor;
|
||||||
|
while idx < self.input.len() {
|
||||||
|
idx += 1;
|
||||||
|
if self.input.is_char_boundary(idx) {
|
||||||
|
self.input_cursor = idx;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.input_cursor = self.input.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_at_cursor(&mut self, c: char) {
|
||||||
|
// Defensive clamp: callers (and tests) may mutate
|
||||||
|
// `input` directly; keep the cursor inside the buffer.
|
||||||
|
if self.input_cursor > self.input.len() {
|
||||||
|
self.input_cursor = self.input.len();
|
||||||
|
}
|
||||||
|
self.input.insert(self.input_cursor, c);
|
||||||
|
self.input_cursor += c.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_before_cursor(&mut self) {
|
||||||
|
if self.input_cursor == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Find the start of the previous character.
|
||||||
|
let mut idx = self.input_cursor - 1;
|
||||||
|
while !self.input.is_char_boundary(idx) {
|
||||||
|
idx -= 1;
|
||||||
|
}
|
||||||
|
self.input.replace_range(idx..self.input_cursor, "");
|
||||||
|
self.input_cursor = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_at_cursor(&mut self) {
|
||||||
|
if self.input_cursor >= self.input.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Find the end of the character at the cursor.
|
||||||
|
let mut idx = self.input_cursor + 1;
|
||||||
|
while idx < self.input.len() && !self.input.is_char_boundary(idx) {
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
self.input.replace_range(self.input_cursor..idx, "");
|
||||||
|
}
|
||||||
|
|
||||||
/// Move backwards in history (towards older entries).
|
/// Move backwards in history (towards older entries).
|
||||||
fn history_back(&mut self) {
|
fn history_back(&mut self) {
|
||||||
if self.history.is_empty() {
|
if self.history.is_empty() {
|
||||||
@@ -200,6 +324,7 @@ impl App {
|
|||||||
};
|
};
|
||||||
self.history_cursor = Some(next_index);
|
self.history_cursor = Some(next_index);
|
||||||
self.input = self.history[next_index].clone();
|
self.input = self.history[next_index].clone();
|
||||||
|
self.input_cursor = self.input.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move forwards in history (towards newer entries; eventually
|
/// Move forwards in history (towards newer entries; eventually
|
||||||
@@ -217,6 +342,7 @@ impl App {
|
|||||||
self.history_cursor = None;
|
self.history_cursor = None;
|
||||||
self.input = self.history_draft.take().unwrap_or_default();
|
self.input = self.history_draft.take().unwrap_or_default();
|
||||||
}
|
}
|
||||||
|
self.input_cursor = self.input.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_history_navigation(&mut self) {
|
fn cancel_history_navigation(&mut self) {
|
||||||
@@ -245,6 +371,7 @@ impl App {
|
|||||||
|
|
||||||
fn submit(&mut self) -> Vec<Action> {
|
fn submit(&mut self) -> Vec<Action> {
|
||||||
let raw = std::mem::take(&mut self.input);
|
let raw = std::mem::take(&mut self.input);
|
||||||
|
self.input_cursor = 0;
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -339,14 +466,45 @@ impl App {
|
|||||||
col.name, type_display, pk, nn
|
col.name, type_display, pk, nn
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if !desc.outbound_relationships.is_empty() {
|
||||||
|
self.note_system(" References:");
|
||||||
|
for r in &desc.outbound_relationships {
|
||||||
|
self.note_system(format!(
|
||||||
|
" {} → {}.{} ({}, on delete {}, on update {})",
|
||||||
|
r.local_column,
|
||||||
|
r.other_table,
|
||||||
|
r.other_column,
|
||||||
|
r.name,
|
||||||
|
r.on_delete,
|
||||||
|
r.on_update,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !desc.inbound_relationships.is_empty() {
|
||||||
|
self.note_system(" Referenced by:");
|
||||||
|
for r in &desc.inbound_relationships {
|
||||||
|
self.note_system(format!(
|
||||||
|
" {}.{} → {} ({}, on delete {}, on update {})",
|
||||||
|
r.other_table,
|
||||||
|
r.other_column,
|
||||||
|
r.local_column,
|
||||||
|
r.name,
|
||||||
|
r.on_delete,
|
||||||
|
r.on_update,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.current_table = description;
|
self.current_table = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
|
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
|
||||||
warn!(verb = command.verb(), error, "dsl command failed");
|
warn!(verb = command.verb(), error, "dsl command failed");
|
||||||
|
// Wrap the command portion in quotes so the message
|
||||||
|
// reads cleanly: "...failed: <reason>" rather than the
|
||||||
|
// command running into "failed: ..." with no break.
|
||||||
self.note_error(format!(
|
self.note_error(format!(
|
||||||
"{} {} failed: {error}",
|
"\"{} {}\" failed: {error}",
|
||||||
command.verb(),
|
command.verb(),
|
||||||
command.target_table()
|
command.target_table()
|
||||||
));
|
));
|
||||||
@@ -391,6 +549,26 @@ impl App {
|
|||||||
while self.output.len() > OUTPUT_CAPACITY {
|
while self.output.len() > OUTPUT_CAPACITY {
|
||||||
self.output.pop_front();
|
self.output.pop_front();
|
||||||
}
|
}
|
||||||
|
// Any new line resets the scroll so freshly-arrived
|
||||||
|
// output is always visible. The user can PageUp again
|
||||||
|
// to inspect history.
|
||||||
|
self.output_scroll = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scroll_output_up(&mut self) {
|
||||||
|
// Cap at `len - visible` so the topmost visible chunk
|
||||||
|
// is the *first* `visible` lines of the buffer; going
|
||||||
|
// past that would shrink the view by sliding the window
|
||||||
|
// off the top.
|
||||||
|
let max = self
|
||||||
|
.output
|
||||||
|
.len()
|
||||||
|
.saturating_sub(self.last_output_visible.max(1));
|
||||||
|
self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn scroll_output_down(&mut self) {
|
||||||
|
self.output_scroll = self.output_scroll.saturating_sub(PAGE_SCROLL_LINES);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +615,8 @@ mod tests {
|
|||||||
notnull: false,
|
notnull: false,
|
||||||
primary_key: true,
|
primary_key: true,
|
||||||
}],
|
}],
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +900,231 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_moves_cursor_to_end_of_input() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
assert_eq!(app.input, "hello");
|
||||||
|
assert_eq!(app.input_cursor, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left_arrow_moves_cursor_back_one_char() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.input_cursor, 4);
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.input_cursor, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left_arrow_at_zero_does_not_underflow() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn right_arrow_moves_cursor_forward() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.input_cursor = 0;
|
||||||
|
app.update(key(KeyCode::Right));
|
||||||
|
assert_eq!(app.input_cursor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_and_end_jump_to_extremes() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.update(key(KeyCode::Home));
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
app.update(key(KeyCode::End));
|
||||||
|
assert_eq!(app.input_cursor, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_inserts_at_cursor_position() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
// Cursor between 'h' and 'e'.
|
||||||
|
app.input_cursor = 1;
|
||||||
|
type_str(&mut app, "X");
|
||||||
|
assert_eq!(app.input, "hXello");
|
||||||
|
assert_eq!(app.input_cursor, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_removes_char_before_cursor() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
// Cursor at end.
|
||||||
|
app.update(key(KeyCode::Backspace));
|
||||||
|
assert_eq!(app.input, "hell");
|
||||||
|
assert_eq!(app.input_cursor, 4);
|
||||||
|
|
||||||
|
// Cursor in the middle.
|
||||||
|
app.input_cursor = 2; // between 'e' and 'l'
|
||||||
|
app.update(key(KeyCode::Backspace));
|
||||||
|
assert_eq!(app.input, "hll");
|
||||||
|
assert_eq!(app.input_cursor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_at_start_is_a_noop() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.input_cursor = 0;
|
||||||
|
app.update(key(KeyCode::Backspace));
|
||||||
|
assert_eq!(app.input, "hello");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_char_at_cursor() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.input_cursor = 1; // between 'h' and 'e'
|
||||||
|
app.update(key(KeyCode::Delete));
|
||||||
|
assert_eq!(app.input, "hllo");
|
||||||
|
assert_eq!(app.input_cursor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_at_end_is_a_noop() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.update(key(KeyCode::Delete));
|
||||||
|
assert_eq!(app.input, "hello");
|
||||||
|
assert_eq!(app.input_cursor, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_handles_multibyte_chars() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "héllo"); // 'é' is 2 bytes
|
||||||
|
// input length is 6 bytes, 5 chars
|
||||||
|
assert_eq!(app.input.len(), 6);
|
||||||
|
assert_eq!(app.input_cursor, 6);
|
||||||
|
// Move left across the 2-byte char.
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.input_cursor, 5);
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.input_cursor, 4);
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
assert_eq!(app.input_cursor, 3);
|
||||||
|
app.update(key(KeyCode::Left));
|
||||||
|
// Now at the byte before 'é' — must skip the multi-byte char.
|
||||||
|
assert_eq!(app.input_cursor, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn submit_resets_cursor_to_zero() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table T");
|
||||||
|
submit(&mut app);
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_up_scrolls_output_back() {
|
||||||
|
let mut app = App::new();
|
||||||
|
for i in 0..30 {
|
||||||
|
app.note_system(format!("line{i}"));
|
||||||
|
}
|
||||||
|
assert_eq!(app.output_scroll, 0);
|
||||||
|
app.update(key(KeyCode::PageUp));
|
||||||
|
assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_down_scrolls_output_back_to_bottom() {
|
||||||
|
let mut app = App::new();
|
||||||
|
for i in 0..30 {
|
||||||
|
app.note_system(format!("line{i}"));
|
||||||
|
}
|
||||||
|
for _ in 0..3 {
|
||||||
|
app.update(key(KeyCode::PageUp));
|
||||||
|
}
|
||||||
|
assert!(app.output_scroll > 0);
|
||||||
|
for _ in 0..10 {
|
||||||
|
app.update(key(KeyCode::PageDown));
|
||||||
|
}
|
||||||
|
assert_eq!(app.output_scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_output_resets_scroll_to_zero() {
|
||||||
|
let mut app = App::new();
|
||||||
|
for i in 0..30 {
|
||||||
|
app.note_system(format!("line{i}"));
|
||||||
|
}
|
||||||
|
app.update(key(KeyCode::PageUp));
|
||||||
|
assert!(app.output_scroll > 0);
|
||||||
|
// Any new output line snaps the scroll back to bottom so
|
||||||
|
// the user always sees the latest result after a command.
|
||||||
|
app.note_system("fresh");
|
||||||
|
assert_eq!(app.output_scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_up_caps_at_top_of_buffer() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.note_system("only line");
|
||||||
|
// Many PageUps in a row should not push past the buffer.
|
||||||
|
for _ in 0..50 {
|
||||||
|
app.update(key(KeyCode::PageUp));
|
||||||
|
}
|
||||||
|
// With 1 line in the buffer, the maximum scroll is 0
|
||||||
|
// (since there's nothing older to reveal).
|
||||||
|
assert_eq!(app.output_scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_up_at_top_of_buffer_does_not_shrink_visible_window() {
|
||||||
|
// Regression: extra PageUps past the top used to drift
|
||||||
|
// `output_scroll` higher than `len - visible`, which
|
||||||
|
// then made the rendered window slide off the top and
|
||||||
|
// appeared to "eat" lines from the bottom.
|
||||||
|
let mut app = App::new();
|
||||||
|
for i in 0..30 {
|
||||||
|
app.note_system(format!("line{i}"));
|
||||||
|
}
|
||||||
|
// Simulate a render reporting 10 visible rows.
|
||||||
|
app.note_output_viewport(10);
|
||||||
|
// Page up many times — past the maximum useful scroll.
|
||||||
|
for _ in 0..20 {
|
||||||
|
app.update(key(KeyCode::PageUp));
|
||||||
|
}
|
||||||
|
// Cap should be at len - visible = 30 - 10 = 20.
|
||||||
|
assert_eq!(app.output_scroll, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn note_output_viewport_clamps_a_drifted_scroll_value() {
|
||||||
|
// If the scroll value was set high while the viewport
|
||||||
|
// was unknown (e.g. before the first render), the next
|
||||||
|
// render's report should bring it back into range.
|
||||||
|
let mut app = App::new();
|
||||||
|
for i in 0..30 {
|
||||||
|
app.note_system(format!("line{i}"));
|
||||||
|
}
|
||||||
|
app.output_scroll = 100;
|
||||||
|
app.note_output_viewport(10);
|
||||||
|
assert_eq!(app.output_scroll, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_recall_places_cursor_at_end() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table A");
|
||||||
|
submit(&mut app);
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "drop table A");
|
||||||
|
assert_eq!(app.input_cursor, "drop table A".len());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn history_records_submitted_lines() {
|
fn history_records_submitted_lines() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
//! Referential actions for foreign-key relationships.
|
||||||
|
//!
|
||||||
|
//! These map directly onto SQLite's `ON DELETE` / `ON UPDATE`
|
||||||
|
//! clause vocabulary. `SET DEFAULT` is intentionally omitted
|
||||||
|
//! until column DEFAULTs (C3 partial) are supported.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ReferentialAction {
|
||||||
|
/// Default — referenced rows can't be deleted while
|
||||||
|
/// referencing rows exist (deferred check).
|
||||||
|
NoAction,
|
||||||
|
/// Like NoAction but immediate.
|
||||||
|
Restrict,
|
||||||
|
/// On parent row delete/update, set the FK column to NULL.
|
||||||
|
SetNull,
|
||||||
|
/// On parent row delete/update, propagate to dependent rows.
|
||||||
|
Cascade,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReferentialAction {
|
||||||
|
/// The user-facing keyword as it appears in DSL input. Note
|
||||||
|
/// `set null` is two words; the parser handles that as a
|
||||||
|
/// single phrase.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn keyword(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::NoAction => "no action",
|
||||||
|
Self::Restrict => "restrict",
|
||||||
|
Self::SetNull => "set null",
|
||||||
|
Self::Cascade => "cascade",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The corresponding SQL clause as written in DDL.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn sql_clause(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::NoAction => "NO ACTION",
|
||||||
|
Self::Restrict => "RESTRICT",
|
||||||
|
Self::SetNull => "SET NULL",
|
||||||
|
Self::Cascade => "CASCADE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All actions, in stable order.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn all() -> &'static [Self] {
|
||||||
|
&[Self::NoAction, Self::Restrict, Self::SetNull, Self::Cascade]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default action when none is specified — matches the SQL
|
||||||
|
/// standard.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn default_action() -> Self {
|
||||||
|
Self::NoAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ReferentialAction {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::default_action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ReferentialAction {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.keyword())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error returned when parsing an unknown action keyword.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("unknown referential action '{found}' (expected one of: {expected})")]
|
||||||
|
pub struct UnknownAction {
|
||||||
|
pub found: String,
|
||||||
|
pub expected: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ReferentialAction {
|
||||||
|
type Err = UnknownAction;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
// Case-insensitive comparison; tolerates internal
|
||||||
|
// whitespace ("set null") by collapsing it.
|
||||||
|
let normalised: String = s
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
for &action in Self::all() {
|
||||||
|
if normalised == action.keyword() {
|
||||||
|
return Ok(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(UnknownAction {
|
||||||
|
found: s.to_string(),
|
||||||
|
expected: Self::all()
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.keyword())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyword_round_trip() {
|
||||||
|
for &a in ReferentialAction::all() {
|
||||||
|
assert_eq!(a.keyword().parse::<ReferentialAction>().unwrap(), a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsing_is_case_insensitive_and_whitespace_tolerant() {
|
||||||
|
assert_eq!(
|
||||||
|
"Cascade".parse::<ReferentialAction>().unwrap(),
|
||||||
|
ReferentialAction::Cascade
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"SET NULL".parse::<ReferentialAction>().unwrap(),
|
||||||
|
ReferentialAction::SetNull
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"set null".parse::<ReferentialAction>().unwrap(),
|
||||||
|
ReferentialAction::SetNull
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"No Action".parse::<ReferentialAction>().unwrap(),
|
||||||
|
ReferentialAction::NoAction
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_action_lists_alternatives() {
|
||||||
|
let err = "destroy".parse::<ReferentialAction>().unwrap_err();
|
||||||
|
assert_eq!(err.found, "destroy");
|
||||||
|
assert!(err.expected.contains("cascade"));
|
||||||
|
assert!(err.expected.contains("set null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sql_clause_mapping() {
|
||||||
|
assert_eq!(ReferentialAction::SetNull.sql_clause(), "SET NULL");
|
||||||
|
assert_eq!(ReferentialAction::NoAction.sql_clause(), "NO ACTION");
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-3
@@ -11,6 +11,7 @@
|
|||||||
//! primary key`, junction-table convenience commands) emit into
|
//! primary key`, junction-table convenience commands) emit into
|
||||||
//! the same shape.
|
//! the same shape.
|
||||||
|
|
||||||
|
use crate::dsl::action::ReferentialAction;
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
|
|
||||||
/// A column at table-creation time: a name and a user-facing
|
/// A column at table-creation time: a name and a user-facing
|
||||||
@@ -42,6 +43,64 @@ pub enum Command {
|
|||||||
column: String,
|
column: String,
|
||||||
ty: Type,
|
ty: Type,
|
||||||
},
|
},
|
||||||
|
/// Establish a 1:n relationship: parent_table.parent_column
|
||||||
|
/// is the primary-key side; child_table.child_column is the
|
||||||
|
/// foreign-key side. `name` is optional — when `None`, the
|
||||||
|
/// executor auto-generates one (`<Child>_<column>_to_<Parent>`).
|
||||||
|
/// `create_fk` requests the child column be created
|
||||||
|
/// automatically with the appropriate type if it is missing.
|
||||||
|
AddRelationship {
|
||||||
|
name: Option<String>,
|
||||||
|
parent_table: String,
|
||||||
|
parent_column: String,
|
||||||
|
child_table: String,
|
||||||
|
child_column: String,
|
||||||
|
on_delete: ReferentialAction,
|
||||||
|
on_update: ReferentialAction,
|
||||||
|
create_fk: bool,
|
||||||
|
},
|
||||||
|
/// Drop a relationship by either user-given/auto-generated
|
||||||
|
/// name, or by positional reference to the FK endpoints.
|
||||||
|
DropRelationship {
|
||||||
|
selector: RelationshipSelector,
|
||||||
|
},
|
||||||
|
/// Re-display a table's structure in the output. Doesn't
|
||||||
|
/// change schema; useful when the user wants to look at a
|
||||||
|
/// table they aren't currently DDL'ing on.
|
||||||
|
ShowTable {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a `drop relationship` command identifies the relationship
|
||||||
|
/// to remove. Both forms are accepted; the executor resolves to
|
||||||
|
/// a single row in the metadata table.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum RelationshipSelector {
|
||||||
|
Named { name: String },
|
||||||
|
Endpoints {
|
||||||
|
parent_table: String,
|
||||||
|
parent_column: String,
|
||||||
|
child_table: String,
|
||||||
|
child_column: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RelationshipSelector {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Named { name } => write!(f, "{name}"),
|
||||||
|
Self::Endpoints {
|
||||||
|
parent_table,
|
||||||
|
parent_column,
|
||||||
|
child_table,
|
||||||
|
child_column,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Command {
|
impl Command {
|
||||||
@@ -52,16 +111,31 @@ impl Command {
|
|||||||
Self::CreateTable { .. } => "create table",
|
Self::CreateTable { .. } => "create table",
|
||||||
Self::DropTable { .. } => "drop table",
|
Self::DropTable { .. } => "drop table",
|
||||||
Self::AddColumn { .. } => "add column",
|
Self::AddColumn { .. } => "add column",
|
||||||
|
Self::AddRelationship { .. } => "add relationship",
|
||||||
|
Self::DropRelationship { .. } => "drop relationship",
|
||||||
|
Self::ShowTable { .. } => "show table",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The table this command targets — every Command in this
|
/// The table whose structure most directly reflects the
|
||||||
/// iteration operates on exactly one table.
|
/// outcome of this command. For relationships this is the
|
||||||
|
/// child table, since the FK constraint physically belongs
|
||||||
|
/// there and our describe view shows both sides anyway.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn target_table(&self) -> &str {
|
pub fn target_table(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::CreateTable { name, .. } | Self::DropTable { name } => name,
|
Self::CreateTable { name, .. }
|
||||||
|
| Self::DropTable { name }
|
||||||
|
| Self::ShowTable { name } => name,
|
||||||
Self::AddColumn { table, .. } => table,
|
Self::AddColumn { table, .. } => table,
|
||||||
|
Self::AddRelationship { child_table, .. } => child_table,
|
||||||
|
Self::DropRelationship { selector } => match selector {
|
||||||
|
RelationshipSelector::Endpoints { child_table, .. } => child_table,
|
||||||
|
// For a named drop we don't know the child table
|
||||||
|
// until the executor resolves it; the verb is
|
||||||
|
// still a sensible fallback for logging.
|
||||||
|
RelationshipSelector::Named { name } => name,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -9,10 +9,12 @@
|
|||||||
//! this module — that path uses `sqlparser-rs` and lives
|
//! this module — that path uses `sqlparser-rs` and lives
|
||||||
//! elsewhere when it lands.
|
//! elsewhere when it lands.
|
||||||
|
|
||||||
|
pub mod action;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use command::{ColumnSpec, Command};
|
pub use action::ReferentialAction;
|
||||||
|
pub use command::{ColumnSpec, Command, RelationshipSelector};
|
||||||
pub use parser::{ParseError, parse_command};
|
pub use parser::{ParseError, parse_command};
|
||||||
pub use types::Type;
|
pub use types::Type;
|
||||||
|
|||||||
+373
-2
@@ -13,7 +13,8 @@
|
|||||||
use chumsky::error::RichReason;
|
use chumsky::error::RichReason;
|
||||||
use chumsky::prelude::*;
|
use chumsky::prelude::*;
|
||||||
|
|
||||||
use crate::dsl::command::{ColumnSpec, Command};
|
use crate::dsl::action::ReferentialAction;
|
||||||
|
use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector};
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
@@ -139,11 +140,180 @@ fn command_parser<'a>()
|
|||||||
.then_ignore(just(')').padded())
|
.then_ignore(just(')').padded())
|
||||||
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
|
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
|
||||||
|
|
||||||
choice((create_table, drop_table, add_column))
|
let add_relationship = add_relationship_parser();
|
||||||
|
let drop_relationship = drop_relationship_parser();
|
||||||
|
|
||||||
|
let show_table = keyword_ci("show")
|
||||||
|
.ignore_then(keyword_ci("table"))
|
||||||
|
.ignore_then(identifier())
|
||||||
|
.map(|name| Command::ShowTable { name });
|
||||||
|
|
||||||
|
choice((
|
||||||
|
create_table,
|
||||||
|
drop_table,
|
||||||
|
add_column,
|
||||||
|
add_relationship,
|
||||||
|
drop_relationship,
|
||||||
|
show_table,
|
||||||
|
))
|
||||||
.padded()
|
.padded()
|
||||||
.then_ignore(end())
|
.then_ignore(end())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `add 1:n relationship [<name>] from <P>.<col> to <C>.<col>
|
||||||
|
/// [on delete <action>] [on update <action>] [--create-fk]`.
|
||||||
|
fn add_relationship_parser<'a>()
|
||||||
|
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
|
||||||
|
let one_to_n = just('1').padded().ignore_then(just(':').padded()).ignore_then(
|
||||||
|
any()
|
||||||
|
.filter(|c: &char| *c == 'n' || *c == 'N')
|
||||||
|
.padded(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let optional_name = keyword_ci("as").ignore_then(identifier()).or_not();
|
||||||
|
|
||||||
|
keyword_ci("add")
|
||||||
|
.ignore_then(one_to_n)
|
||||||
|
.ignore_then(keyword_ci("relationship"))
|
||||||
|
.ignore_then(optional_name)
|
||||||
|
.then_ignore(keyword_ci("from"))
|
||||||
|
.then(qualified_column())
|
||||||
|
.then_ignore(keyword_ci("to"))
|
||||||
|
.then(qualified_column())
|
||||||
|
.then(referential_clauses())
|
||||||
|
.then(create_fk_flag())
|
||||||
|
.map(
|
||||||
|
|((((name, parent), child), (on_delete, on_update)), create_fk)| {
|
||||||
|
Command::AddRelationship {
|
||||||
|
name,
|
||||||
|
parent_table: parent.0,
|
||||||
|
parent_column: parent.1,
|
||||||
|
child_table: child.0,
|
||||||
|
child_column: child.1,
|
||||||
|
on_delete,
|
||||||
|
on_update,
|
||||||
|
create_fk,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `drop relationship <name>` or
|
||||||
|
/// `drop relationship from <P>.<col> to <C>.<col>`.
|
||||||
|
fn drop_relationship_parser<'a>()
|
||||||
|
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
|
||||||
|
let endpoints_form = keyword_ci("from")
|
||||||
|
.ignore_then(qualified_column())
|
||||||
|
.then_ignore(keyword_ci("to"))
|
||||||
|
.then(qualified_column())
|
||||||
|
.map(|(parent, child)| RelationshipSelector::Endpoints {
|
||||||
|
parent_table: parent.0,
|
||||||
|
parent_column: parent.1,
|
||||||
|
child_table: child.0,
|
||||||
|
child_column: child.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let named_form = identifier().map(|name| RelationshipSelector::Named { name });
|
||||||
|
|
||||||
|
keyword_ci("drop")
|
||||||
|
.ignore_then(keyword_ci("relationship"))
|
||||||
|
.ignore_then(choice((endpoints_form, named_form)))
|
||||||
|
.map(|selector| Command::DropRelationship { selector })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `<Table>.<Column>` returning (table, column).
|
||||||
|
fn qualified_column<'a>()
|
||||||
|
-> impl Parser<'a, &'a str, (String, String), extra::Err<Rich<'a, char>>> + Clone {
|
||||||
|
identifier()
|
||||||
|
.then_ignore(just('.').padded())
|
||||||
|
.then(identifier())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional `on delete <action>` and/or `on update <action>`,
|
||||||
|
/// in either order. Default to `NoAction` when omitted.
|
||||||
|
fn referential_clauses<'a>() -> impl Parser<
|
||||||
|
'a,
|
||||||
|
&'a str,
|
||||||
|
(ReferentialAction, ReferentialAction),
|
||||||
|
extra::Err<Rich<'a, char>>,
|
||||||
|
> + Clone {
|
||||||
|
let target = keyword_ci("delete")
|
||||||
|
.to(ReferentialActionTarget::Delete)
|
||||||
|
.or(keyword_ci("update").to(ReferentialActionTarget::Update));
|
||||||
|
let clause = keyword_ci("on")
|
||||||
|
.ignore_then(target)
|
||||||
|
.then(action_keyword())
|
||||||
|
.map(|(t, a)| (t, a));
|
||||||
|
clause
|
||||||
|
.repeated()
|
||||||
|
.at_most(2)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.try_map(|clauses, span| {
|
||||||
|
let mut on_delete = None;
|
||||||
|
let mut on_update = None;
|
||||||
|
for (target, action) in clauses {
|
||||||
|
let slot = match target {
|
||||||
|
ReferentialActionTarget::Delete => &mut on_delete,
|
||||||
|
ReferentialActionTarget::Update => &mut on_update,
|
||||||
|
};
|
||||||
|
if slot.is_some() {
|
||||||
|
return Err(Rich::custom(
|
||||||
|
span,
|
||||||
|
format!("`on {target}` specified twice"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*slot = Some(action);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
on_delete.unwrap_or_else(ReferentialAction::default_action),
|
||||||
|
on_update.unwrap_or_else(ReferentialAction::default_action),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum ReferentialActionTarget {
|
||||||
|
Delete,
|
||||||
|
Update,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ReferentialActionTarget {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Delete => "delete",
|
||||||
|
Self::Update => "update",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a referential-action keyword: `cascade`, `restrict`,
|
||||||
|
/// `set null`, or `no action`. The two-word forms come first in
|
||||||
|
/// the alternatives so they're tried before the one-word forms;
|
||||||
|
/// because the first words are unique to each phrase
|
||||||
|
/// (`set`/`no` for two-word, `cascade`/`restrict` for one-word)
|
||||||
|
/// there is no ambiguity.
|
||||||
|
fn action_keyword<'a>()
|
||||||
|
-> impl Parser<'a, &'a str, ReferentialAction, extra::Err<Rich<'a, char>>> + Clone {
|
||||||
|
choice((
|
||||||
|
keyword_ci("set")
|
||||||
|
.ignore_then(keyword_ci("null"))
|
||||||
|
.to(ReferentialAction::SetNull),
|
||||||
|
keyword_ci("no")
|
||||||
|
.ignore_then(keyword_ci("action"))
|
||||||
|
.to(ReferentialAction::NoAction),
|
||||||
|
keyword_ci("cascade").to(ReferentialAction::Cascade),
|
||||||
|
keyword_ci("restrict").to(ReferentialAction::Restrict),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_fk_flag<'a>()
|
||||||
|
-> impl Parser<'a, &'a str, bool, extra::Err<Rich<'a, char>>> + Clone {
|
||||||
|
just("--create-fk")
|
||||||
|
.padded()
|
||||||
|
.or_not()
|
||||||
|
.map(|opt| opt.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse the optional `with pk [<spec>]` clause that may follow
|
/// Parse the optional `with pk [<spec>]` clause that may follow
|
||||||
/// `create table <Name>`. Returns the list of (name, type) pairs
|
/// `create table <Name>`. Returns the list of (name, type) pairs
|
||||||
/// that form the primary key. An absent clause returns an empty
|
/// that form the primary key. An absent clause returns an empty
|
||||||
@@ -471,6 +641,207 @@ mod tests {
|
|||||||
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rel(
|
||||||
|
name: Option<&str>,
|
||||||
|
parent: (&str, &str),
|
||||||
|
child: (&str, &str),
|
||||||
|
on_delete: ReferentialAction,
|
||||||
|
on_update: ReferentialAction,
|
||||||
|
create_fk: bool,
|
||||||
|
) -> Command {
|
||||||
|
Command::AddRelationship {
|
||||||
|
name: name.map(String::from),
|
||||||
|
parent_table: parent.0.to_string(),
|
||||||
|
parent_column: parent.1.to_string(),
|
||||||
|
child_table: child.0.to_string(),
|
||||||
|
child_column: child.1.to_string(),
|
||||||
|
on_delete,
|
||||||
|
on_update,
|
||||||
|
create_fk,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_minimal() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship from Customers.Id to Orders.CustId"),
|
||||||
|
rel(
|
||||||
|
None,
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_with_name() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId"),
|
||||||
|
rel(
|
||||||
|
Some("cust_orders"),
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_with_on_delete() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade"),
|
||||||
|
rel(
|
||||||
|
None,
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::Cascade,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_with_on_delete_set_null() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete set null"),
|
||||||
|
rel(
|
||||||
|
None,
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::SetNull,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_with_both_actions_in_either_order() {
|
||||||
|
let expected = rel(
|
||||||
|
None,
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::Cascade,
|
||||||
|
ReferentialAction::SetNull,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_repeated_clause_errors() {
|
||||||
|
let e = err(
|
||||||
|
"add 1:n relationship from C.id to O.cid on delete cascade on delete restrict",
|
||||||
|
);
|
||||||
|
match e {
|
||||||
|
ParseError::Invalid { message, .. } => {
|
||||||
|
assert!(message.contains("specified twice"), "{message}");
|
||||||
|
}
|
||||||
|
ParseError::Empty => panic!("unexpected empty error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_with_create_fk_flag() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship from Customers.Id to Orders.CustId --create-fk"),
|
||||||
|
rel(
|
||||||
|
None,
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_with_name_actions_and_flag() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
|
||||||
|
rel(
|
||||||
|
Some("cust_orders"),
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::Cascade,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_keywords_are_case_insensitive() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("ADD 1:N RELATIONSHIP FROM Customers.Id TO Orders.CustId ON DELETE CASCADE"),
|
||||||
|
rel(
|
||||||
|
None,
|
||||||
|
("Customers", "Id"),
|
||||||
|
("Orders", "CustId"),
|
||||||
|
ReferentialAction::Cascade,
|
||||||
|
ReferentialAction::NoAction,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_unknown_action_errors() {
|
||||||
|
let e = err("add 1:n relationship from C.id to O.cid on delete obliterate");
|
||||||
|
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_relationship_by_name() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("drop relationship cust_orders"),
|
||||||
|
Command::DropRelationship {
|
||||||
|
selector: RelationshipSelector::Named {
|
||||||
|
name: "cust_orders".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_table_simple() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("show table Customers"),
|
||||||
|
Command::ShowTable {
|
||||||
|
name: "Customers".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_relationship_by_endpoints() {
|
||||||
|
assert_eq!(
|
||||||
|
ok("drop relationship from Customers.Id to Orders.CustId"),
|
||||||
|
Command::DropRelationship {
|
||||||
|
selector: RelationshipSelector::Endpoints {
|
||||||
|
parent_table: "Customers".to_string(),
|
||||||
|
parent_column: "Id".to_string(),
|
||||||
|
child_table: "Orders".to_string(),
|
||||||
|
child_column: "CustId".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn identifier_allows_underscores_and_digits_after_start() {
|
fn identifier_allows_underscores_and_digits_after_start() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+34
-2
@@ -68,7 +68,7 @@ async fn run_loop(
|
|||||||
seed_initial_tables(&database, &event_tx).await;
|
seed_initial_tables(&database, &event_tx).await;
|
||||||
|
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| ui::render(&app, &theme, f))
|
.draw(|f| ui::render(&mut app, &theme, f))
|
||||||
.context("initial draw")?;
|
.context("initial draw")?;
|
||||||
|
|
||||||
info!("entering main event loop");
|
info!("entering main event loop");
|
||||||
@@ -87,7 +87,7 @@ async fn run_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| ui::render(&app, &theme, f))
|
.draw(|f| ui::render(&mut app, &theme, f))
|
||||||
.context("redraw")?;
|
.context("redraw")?;
|
||||||
if should_quit {
|
if should_quit {
|
||||||
break;
|
break;
|
||||||
@@ -170,6 +170,38 @@ async fn execute_command(
|
|||||||
.await
|
.await
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(friendly),
|
.map_err(friendly),
|
||||||
|
Command::AddRelationship {
|
||||||
|
name,
|
||||||
|
parent_table,
|
||||||
|
parent_column,
|
||||||
|
child_table,
|
||||||
|
child_column,
|
||||||
|
on_delete,
|
||||||
|
on_update,
|
||||||
|
create_fk,
|
||||||
|
} => database
|
||||||
|
.add_relationship(
|
||||||
|
name,
|
||||||
|
parent_table,
|
||||||
|
parent_column,
|
||||||
|
child_table,
|
||||||
|
child_column,
|
||||||
|
on_delete,
|
||||||
|
on_update,
|
||||||
|
create_fk,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(friendly),
|
||||||
|
Command::DropRelationship { selector } => database
|
||||||
|
.drop_relationship(selector)
|
||||||
|
.await
|
||||||
|
.map_err(friendly),
|
||||||
|
Command::ShowTable { name } => database
|
||||||
|
.describe_table(name)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(friendly),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ use crate::mode::Mode;
|
|||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
|
|
||||||
/// Render the entire application frame.
|
/// Render the entire application frame.
|
||||||
pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
|
///
|
||||||
|
/// Takes `&mut App` because the renderer reports the current
|
||||||
|
/// output-panel row count back to the App for scroll-cap
|
||||||
|
/// computation — without that feedback, scrolling past the top
|
||||||
|
/// of the buffer would slide the visible window off and
|
||||||
|
/// "eat" lines from the bottom on subsequent renders.
|
||||||
|
pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
paint_background(theme, frame, area);
|
paint_background(theme, frame, area);
|
||||||
|
|
||||||
@@ -37,7 +43,7 @@ pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) {
|
|||||||
render_status_bar(app, theme, frame, outer[1]);
|
render_status_bar(app, theme, frame, outer[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_right_column(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -105,7 +111,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
@@ -123,15 +129,25 @@ fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Re
|
|||||||
vertical: 1,
|
vertical: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show the most recent lines that fit. The output buffer is
|
// Show a window of the buffer ending `output_scroll` lines
|
||||||
// append-only, so taking from the back gives "most recent".
|
// above the most recent entry. With scroll == 0 the last
|
||||||
|
// line shown is the most recent; PageUp increases the
|
||||||
|
// scroll, revealing older lines. We report the visible row
|
||||||
|
// count back to App so input handling can cap scroll
|
||||||
|
// correctly between renders (otherwise scroll could drift
|
||||||
|
// past the top and slide the window off).
|
||||||
let visible = inner.height as usize;
|
let visible = inner.height as usize;
|
||||||
|
app.note_output_viewport(visible);
|
||||||
|
let total = app.output.len();
|
||||||
|
let max_scroll = total.saturating_sub(visible);
|
||||||
|
let effective_scroll = app.output_scroll.min(max_scroll);
|
||||||
|
let end = total - effective_scroll;
|
||||||
|
let start = end.saturating_sub(visible);
|
||||||
let lines: Vec<Line<'_>> = app
|
let lines: Vec<Line<'_>> = app
|
||||||
.output
|
.output
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.skip(start)
|
||||||
.take(visible)
|
.take(end - start)
|
||||||
.rev()
|
|
||||||
.map(|line| render_output_line(line, theme))
|
.map(|line| render_output_line(line, theme))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -193,11 +209,29 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
.title(title)
|
.title(title)
|
||||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||||
|
|
||||||
// Cursor block: the character at the cursor position is rendered
|
// Cursor block: render the character at the cursor position
|
||||||
// inverted so it is visible without enabling a real terminal cursor.
|
// inverted so the cursor is visible without enabling a real
|
||||||
|
// terminal cursor. When the cursor is at end-of-input we
|
||||||
|
// append an inverted space.
|
||||||
|
let cursor = app.input_cursor.min(app.input.len());
|
||||||
|
let before = &app.input[..cursor];
|
||||||
|
let (under, after) = if cursor < app.input.len() {
|
||||||
|
// Find the end of the character under the cursor.
|
||||||
|
let mut end = cursor + 1;
|
||||||
|
while end < app.input.len() && !app.input.is_char_boundary(end) {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
(&app.input[cursor..end], &app.input[end..])
|
||||||
|
} else {
|
||||||
|
(" ", "")
|
||||||
|
};
|
||||||
let spans = vec![
|
let spans = vec![
|
||||||
Span::styled(app.input.as_str(), Style::default().fg(theme.fg)),
|
Span::styled(before, Style::default().fg(theme.fg)),
|
||||||
Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)),
|
Span::styled(
|
||||||
|
under,
|
||||||
|
Style::default().fg(theme.fg).add_modifier(Modifier::REVERSED),
|
||||||
|
),
|
||||||
|
Span::styled(after, Style::default().fg(theme.fg)),
|
||||||
];
|
];
|
||||||
let paragraph = Paragraph::new(Line::from(spans)).block(block);
|
let paragraph = Paragraph::new(Line::from(spans)).block(block);
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
@@ -273,7 +307,7 @@ mod tests {
|
|||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
fn render_to_string(app: &App, theme: &Theme, width: u16, height: u16) -> String {
|
fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
|
||||||
let backend = TestBackend::new(width, height);
|
let backend = TestBackend::new(width, height);
|
||||||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||||||
terminal
|
terminal
|
||||||
@@ -292,17 +326,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dark_theme_default_view_snapshot() {
|
fn dark_theme_default_view_snapshot() {
|
||||||
let app = App::new();
|
let mut app = App::new();
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
let snapshot = render_to_string(&app, &theme, 80, 24);
|
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||||
insta::assert_snapshot!("default_simple_dark", snapshot);
|
insta::assert_snapshot!("default_simple_dark", snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn light_theme_default_view_snapshot() {
|
fn light_theme_default_view_snapshot() {
|
||||||
let app = App::new();
|
let mut app = App::new();
|
||||||
let theme = Theme::light();
|
let theme = Theme::light();
|
||||||
let snapshot = render_to_string(&app, &theme, 80, 24);
|
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||||
insta::assert_snapshot!("default_simple_light", snapshot);
|
insta::assert_snapshot!("default_simple_light", snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +345,7 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.mode = Mode::Advanced;
|
app.mode = Mode::Advanced;
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
let snapshot = render_to_string(&app, &theme, 80, 24);
|
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||||
insta::assert_snapshot!("default_advanced_dark", snapshot);
|
insta::assert_snapshot!("default_advanced_dark", snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +357,7 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.input.push_str(": sel");
|
app.input.push_str(": sel");
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
let snapshot = render_to_string(&app, &theme, 80, 24);
|
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||||
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
|
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +389,8 @@ mod tests {
|
|||||||
primary_key: false,
|
primary_key: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: Vec::new(),
|
||||||
};
|
};
|
||||||
app.current_table = Some(desc);
|
app.current_table = Some(desc);
|
||||||
// Mirror what the App writes when a DSL command succeeds.
|
// Mirror what the App writes when a DSL command succeeds.
|
||||||
@@ -380,7 +416,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
let snapshot = render_to_string(&app, &theme, 80, 24);
|
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||||
insta::assert_snapshot!("populated_with_table_dark", snapshot);
|
insta::assert_snapshot!("populated_with_table_dark", snapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-19
@@ -12,8 +12,8 @@ use ratatui::backend::TestBackend;
|
|||||||
|
|
||||||
use rdbms_playground::action::Action;
|
use rdbms_playground::action::Action;
|
||||||
use rdbms_playground::app::{App, OutputKind};
|
use rdbms_playground::app::{App, OutputKind};
|
||||||
use rdbms_playground::db::{ColumnDescription, TableDescription};
|
use rdbms_playground::db::{ColumnDescription, RelationshipEnd, TableDescription};
|
||||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type};
|
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type};
|
||||||
use rdbms_playground::event::AppEvent;
|
use rdbms_playground::event::AppEvent;
|
||||||
use rdbms_playground::mode::Mode;
|
use rdbms_playground::mode::Mode;
|
||||||
use rdbms_playground::theme::Theme;
|
use rdbms_playground::theme::Theme;
|
||||||
@@ -40,7 +40,7 @@ fn submit(app: &mut App) -> Vec<Action> {
|
|||||||
app.update(key(KeyCode::Enter))
|
app.update(key(KeyCode::Enter))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String {
|
fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
|
||||||
let backend = TestBackend::new(width, height);
|
let backend = TestBackend::new(width, height);
|
||||||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||||||
terminal
|
terminal
|
||||||
@@ -63,7 +63,7 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() {
|
|||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
|
|
||||||
type_str(&mut app, "create table Customers with pk");
|
type_str(&mut app, "create table Customers with pk");
|
||||||
let pre_render = rendered_text(&app, &theme, 80, 24);
|
let pre_render = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(
|
assert!(
|
||||||
pre_render.contains("create table Customers"),
|
pre_render.contains("create table Customers"),
|
||||||
"input field should display the typed text:\n{pre_render}"
|
"input field should display the typed text:\n{pre_render}"
|
||||||
@@ -82,7 +82,7 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() {
|
|||||||
})]
|
})]
|
||||||
);
|
);
|
||||||
assert!(app.input.is_empty(), "input buffer cleared on submit");
|
assert!(app.input.is_empty(), "input buffer cleared on submit");
|
||||||
let post_render = rendered_text(&app, &theme, 80, 24);
|
let post_render = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(
|
assert!(
|
||||||
post_render.contains("running:"),
|
post_render.contains("running:"),
|
||||||
"output panel should show the running notice:\n{post_render}"
|
"output panel should show the running notice:\n{post_render}"
|
||||||
@@ -96,7 +96,7 @@ fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
|
|||||||
type_str(&mut app, "hello world");
|
type_str(&mut app, "hello world");
|
||||||
let actions = submit(&mut app);
|
let actions = submit(&mut app);
|
||||||
assert!(actions.is_empty());
|
assert!(actions.is_empty());
|
||||||
let rendered = rendered_text(&app, &theme, 80, 24);
|
let rendered = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("parse error"),
|
rendered.contains("parse error"),
|
||||||
"output panel should show the parse error:\n{rendered}"
|
"output panel should show the parse error:\n{rendered}"
|
||||||
@@ -108,7 +108,7 @@ fn mode_switch_changes_label_and_subsequent_echoes() {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
|
|
||||||
let initial = rendered_text(&app, &theme, 80, 24);
|
let initial = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(initial.contains("SIMPLE"));
|
assert!(initial.contains("SIMPLE"));
|
||||||
assert!(!initial.contains("ADVANCED"));
|
assert!(!initial.contains("ADVANCED"));
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ fn mode_switch_changes_label_and_subsequent_echoes() {
|
|||||||
submit(&mut app);
|
submit(&mut app);
|
||||||
assert_eq!(app.mode, Mode::Advanced);
|
assert_eq!(app.mode, Mode::Advanced);
|
||||||
|
|
||||||
let after_switch = rendered_text(&app, &theme, 80, 24);
|
let after_switch = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(after_switch.contains("ADVANCED"));
|
assert!(after_switch.contains("ADVANCED"));
|
||||||
|
|
||||||
type_str(&mut app, "select 1");
|
type_str(&mut app, "select 1");
|
||||||
@@ -162,9 +162,9 @@ fn quit_command_returns_quit_action() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn rendering_works_at_minimum_useful_size() {
|
fn rendering_works_at_minimum_useful_size() {
|
||||||
// Sanity check that the layout does not panic at small sizes.
|
// Sanity check that the layout does not panic at small sizes.
|
||||||
let app = App::new();
|
let mut app = App::new();
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
let _ = rendered_text(&app, &theme, 40, 12);
|
let _ = rendered_text(&mut app, &theme, 40, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -174,14 +174,14 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
|||||||
|
|
||||||
// No `:` yet — prompt shows SIMPLE.
|
// No `:` yet — prompt shows SIMPLE.
|
||||||
type_str(&mut app, "sel");
|
type_str(&mut app, "sel");
|
||||||
let before = rendered_text(&app, &theme, 80, 24);
|
let before = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(before.contains("SIMPLE"));
|
assert!(before.contains("SIMPLE"));
|
||||||
assert!(!before.contains("Advanced:"));
|
assert!(!before.contains("Advanced:"));
|
||||||
|
|
||||||
// Reset and type `:` first — prompt should flip immediately.
|
// Reset and type `:` first — prompt should flip immediately.
|
||||||
app.input.clear();
|
app.input.clear();
|
||||||
type_str(&mut app, ":");
|
type_str(&mut app, ":");
|
||||||
let after_colon = rendered_text(&app, &theme, 80, 24);
|
let after_colon = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(
|
assert!(
|
||||||
after_colon.contains("Advanced:"),
|
after_colon.contains("Advanced:"),
|
||||||
"input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
|
"input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
|
||||||
@@ -193,7 +193,7 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
|||||||
while !app.input.is_empty() {
|
while !app.input.is_empty() {
|
||||||
app.update(key(KeyCode::Backspace));
|
app.update(key(KeyCode::Backspace));
|
||||||
}
|
}
|
||||||
let after_revert = rendered_text(&app, &theme, 80, 24);
|
let after_revert = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(after_revert.contains("SIMPLE"));
|
assert!(after_revert.contains("SIMPLE"));
|
||||||
assert!(!after_revert.contains("Advanced:"));
|
assert!(!after_revert.contains("Advanced:"));
|
||||||
}
|
}
|
||||||
@@ -203,14 +203,14 @@ fn status_bar_lists_quit_and_submit_in_all_modes() {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
|
|
||||||
let simple = rendered_text(&app, &theme, 80, 24);
|
let simple = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(simple.contains("Enter"), "status bar lists Enter");
|
assert!(simple.contains("Enter"), "status bar lists Enter");
|
||||||
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
||||||
assert!(simple.contains("mode advanced"));
|
assert!(simple.contains("mode advanced"));
|
||||||
|
|
||||||
type_str(&mut app, "mode advanced");
|
type_str(&mut app, "mode advanced");
|
||||||
submit(&mut app);
|
submit(&mut app);
|
||||||
let advanced = rendered_text(&app, &theme, 80, 24);
|
let advanced = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(advanced.contains("Enter"));
|
assert!(advanced.contains("Enter"));
|
||||||
assert!(advanced.contains("Ctrl-C"));
|
assert!(advanced.contains("Ctrl-C"));
|
||||||
assert!(advanced.contains("mode simple"));
|
assert!(advanced.contains("mode simple"));
|
||||||
@@ -239,6 +239,8 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
|
|||||||
primary_key: *pk,
|
primary_key: *pk,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +273,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
|||||||
assert_eq!(app.tables, vec!["Customers".to_string()]);
|
assert_eq!(app.tables, vec!["Customers".to_string()]);
|
||||||
assert_eq!(app.current_table, Some(desc));
|
assert_eq!(app.current_table, Some(desc));
|
||||||
|
|
||||||
let rendered = rendered_text(&app, &theme, 80, 24);
|
let rendered = rendered_text(&mut app, &theme, 80, 24);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("Customers"),
|
rendered.contains("Customers"),
|
||||||
"items panel should list Customers:\n{rendered}"
|
"items panel should list Customers:\n{rendered}"
|
||||||
@@ -320,7 +322,7 @@ fn add_column_flow_updates_structure_view() {
|
|||||||
description: Some(updated.clone()),
|
description: Some(updated.clone()),
|
||||||
});
|
});
|
||||||
assert_eq!(app.current_table, Some(updated));
|
assert_eq!(app.current_table, Some(updated));
|
||||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
assert!(rendered.contains("Name text"));
|
assert!(rendered.contains("Name text"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,11 +351,118 @@ fn drop_table_flow_clears_items_list() {
|
|||||||
|
|
||||||
assert!(app.tables.is_empty());
|
assert!(app.tables.is_empty());
|
||||||
assert!(app.current_table.is_none());
|
assert!(app.current_table.is_none());
|
||||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
assert!(rendered.contains("(none yet)"));
|
assert!(rendered.contains("(none yet)"));
|
||||||
assert!(rendered.contains("[ok] drop table Customers"));
|
assert!(rendered.contains("[ok] drop table Customers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_flow_shows_outbound_section() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(
|
||||||
|
&mut app,
|
||||||
|
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade",
|
||||||
|
);
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert_eq!(
|
||||||
|
actions,
|
||||||
|
vec![Action::ExecuteDsl(Command::AddRelationship {
|
||||||
|
name: None,
|
||||||
|
parent_table: "Customers".to_string(),
|
||||||
|
parent_column: "Id".to_string(),
|
||||||
|
child_table: "Orders".to_string(),
|
||||||
|
child_column: "CustId".to_string(),
|
||||||
|
on_delete: ReferentialAction::Cascade,
|
||||||
|
on_update: ReferentialAction::NoAction,
|
||||||
|
create_fk: false,
|
||||||
|
})]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the runtime feeding back a description with the
|
||||||
|
// outbound relationship populated.
|
||||||
|
let orders = TableDescription {
|
||||||
|
name: "Orders".to_string(),
|
||||||
|
columns: vec![
|
||||||
|
ColumnDescription {
|
||||||
|
name: "id".to_string(),
|
||||||
|
user_type: Some(Type::Serial),
|
||||||
|
sqlite_type: "INTEGER".to_string(),
|
||||||
|
notnull: false,
|
||||||
|
primary_key: true,
|
||||||
|
},
|
||||||
|
ColumnDescription {
|
||||||
|
name: "CustId".to_string(),
|
||||||
|
user_type: Some(Type::Int),
|
||||||
|
sqlite_type: "INTEGER".to_string(),
|
||||||
|
notnull: false,
|
||||||
|
primary_key: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbound_relationships: vec![RelationshipEnd {
|
||||||
|
name: "Customers_Id_to_Orders_CustId".to_string(),
|
||||||
|
other_table: "Customers".to_string(),
|
||||||
|
other_column: "Id".to_string(),
|
||||||
|
local_column: "CustId".to_string(),
|
||||||
|
on_delete: ReferentialAction::Cascade,
|
||||||
|
on_update: ReferentialAction::NoAction,
|
||||||
|
}],
|
||||||
|
inbound_relationships: Vec::new(),
|
||||||
|
};
|
||||||
|
app.update(AppEvent::DslSucceeded {
|
||||||
|
command: Command::AddRelationship {
|
||||||
|
name: None,
|
||||||
|
parent_table: "Customers".to_string(),
|
||||||
|
parent_column: "Id".to_string(),
|
||||||
|
child_table: "Orders".to_string(),
|
||||||
|
child_column: "CustId".to_string(),
|
||||||
|
on_delete: ReferentialAction::Cascade,
|
||||||
|
on_update: ReferentialAction::NoAction,
|
||||||
|
create_fk: false,
|
||||||
|
},
|
||||||
|
description: Some(orders),
|
||||||
|
});
|
||||||
|
|
||||||
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
|
assert!(rendered.contains("References:"), "{rendered}");
|
||||||
|
assert!(rendered.contains("CustId → Customers.Id"), "{rendered}");
|
||||||
|
assert!(rendered.contains("on delete cascade"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_relationship_flow_shows_inbound_section_on_parent() {
|
||||||
|
let mut app = App::new();
|
||||||
|
let customers = TableDescription {
|
||||||
|
name: "Customers".to_string(),
|
||||||
|
columns: vec![ColumnDescription {
|
||||||
|
name: "Id".to_string(),
|
||||||
|
user_type: Some(Type::Serial),
|
||||||
|
sqlite_type: "INTEGER".to_string(),
|
||||||
|
notnull: false,
|
||||||
|
primary_key: true,
|
||||||
|
}],
|
||||||
|
outbound_relationships: Vec::new(),
|
||||||
|
inbound_relationships: vec![RelationshipEnd {
|
||||||
|
name: "Customers_Id_to_Orders_CustId".to_string(),
|
||||||
|
other_table: "Orders".to_string(),
|
||||||
|
other_column: "CustId".to_string(),
|
||||||
|
local_column: "Id".to_string(),
|
||||||
|
on_delete: ReferentialAction::Cascade,
|
||||||
|
on_update: ReferentialAction::NoAction,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
app.update(AppEvent::DslSucceeded {
|
||||||
|
command: Command::AddColumn {
|
||||||
|
table: "Customers".to_string(),
|
||||||
|
column: "extra".to_string(),
|
||||||
|
ty: Type::Text,
|
||||||
|
},
|
||||||
|
description: Some(customers),
|
||||||
|
});
|
||||||
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
|
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||||
|
assert!(rendered.contains("Orders.CustId → Id"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dsl_failure_shows_friendly_error_in_output() {
|
fn dsl_failure_shows_friendly_error_in_output() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -365,7 +474,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
|
|||||||
},
|
},
|
||||||
error: "no such table: Ghost".to_string(),
|
error: "no such table: Ghost".to_string(),
|
||||||
});
|
});
|
||||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||||
assert!(
|
assert!(
|
||||||
rendered.contains("Ghost"),
|
rendered.contains("Ghost"),
|
||||||
"error should mention the table:\n{rendered}"
|
"error should mention the table:\n{rendered}"
|
||||||
|
|||||||
Reference in New Issue
Block a user