diff --git a/docs/adr/0013-relationships-and-rebuild-table.md b/docs/adr/0013-relationships-and-rebuild-table.md new file mode 100644 index 0000000..19cb18c --- /dev/null +++ b/docs/adr/0013-relationships-and-rebuild-table.md @@ -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 ] from . to . + [on delete ] + [on update ] + [--create-fk] + +drop relationship +drop relationship from . to . +``` + +- `as ` 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 ` is omitted, the executor generates +`__to__`, matching the +direction of the user's `from . to .` +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_; +CREATE TABLE __rdbms_rebuild_ (...) STRICT; +INSERT INTO __rdbms_rebuild_ (cols-on-both) + SELECT (cols-on-both) FROM ; +DROP TABLE ; +ALTER TABLE __rdbms_rebuild_ RENAME TO ; + +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 [on delete ] [on update +]` 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 ` 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) diff --git a/docs/adr/README.md b/docs/adr/README.md index 80ce8af..73d52b0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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-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-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md) diff --git a/docs/requirements.md b/docs/requirements.md index 3b4fe1c..8539199 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -61,6 +61,17 @@ against it. - [ ] **I1** Multi-line entry that auto-expands; Ctrl-Enter (or 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, with a global rolling history also available). *(Progress: in-memory navigable history (Up/Down arrows, draft @@ -108,7 +119,16 @@ against it. compound), foreign key with `ON DELETE` / `ON UPDATE` referential actions, indexes, `NOT NULL`, `UNIQUE`, `CHECK`, `DEFAULT`. *(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 + [on delete ] [on update ]`. 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 to ` produces an auto-named junction table the user can rename; pulls primary keys and FK definitions automatically. @@ -135,6 +155,10 @@ against it. through a dedicated worker thread per ADR-0010.)* - [ ] **B2** Schema evolution uses the rebuild-table technique 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 cartesian-join-of-doom can hang the app). *(Progress: the worker-thread architecture is in place; the @@ -153,8 +177,9 @@ against it. - [ ] **T3** Compound primary keys handled end-to-end (DSL, storage, display, FK reference). *(Progress: DSL grammar (`with pk a:int,b:int`), storage, and - table-info description are all present; pretty display of the - PK in the structure view and FK reference still pending.)* + table-info description are all present; the FK iteration + references single-column PKs only — compound-key FK references + remain pending.)* ## Visualizations @@ -179,6 +204,14 @@ against it. based on dimensions; the log is exportable to Markdown so learners can keep a record of their session. Design and ADR 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 []` family of commands for + redisplaying schema info on demand. *(Progress: `show table + ` implemented and reuses the structure-render pipeline; + `show tables`, `show relationships`, etc. pending.)* ## Project lifecycle (per ADR-0004) diff --git a/src/app.rs b/src/app.rs index bc7aed0..d3c1f26 100644 --- a/src/app.rs +++ b/src/app.rs @@ -59,6 +59,9 @@ impl EffectiveMode { pub struct App { pub mode: Mode, 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, pub hint: Option, pub tables: Vec, @@ -77,8 +80,24 @@ pub struct App { /// start navigating history, restored if they navigate back /// past the most recent entry. history_draft: Option, + /// 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; impl Default for App { @@ -93,6 +112,7 @@ impl App { Self { mode: Mode::Simple, input: String::new(), + input_cursor: 0, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, tables: Vec::new(), @@ -100,6 +120,22 @@ impl App { history: Vec::new(), history_cursor: 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(); 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, _) => { 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() } (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { self.cancel_history_navigation(); let was_empty = self.input.is_empty(); - self.input.push(c); + self.insert_at_cursor(c); // Convenience: when `:` becomes the leading character in // simple mode, auto-insert a space after it so the input // reads ": foo" rather than ":foo". The trailing space is // an ordinary character — backspace removes it normally. if c == ':' && was_empty && self.mode == Mode::Simple { - self.input.push(' '); + self.insert_at_cursor(' '); } 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). fn history_back(&mut self) { if self.history.is_empty() { @@ -200,6 +324,7 @@ impl App { }; self.history_cursor = Some(next_index); self.input = self.history[next_index].clone(); + self.input_cursor = self.input.len(); } /// Move forwards in history (towards newer entries; eventually @@ -217,6 +342,7 @@ impl App { self.history_cursor = None; self.input = self.history_draft.take().unwrap_or_default(); } + self.input_cursor = self.input.len(); } fn cancel_history_navigation(&mut self) { @@ -245,6 +371,7 @@ impl App { fn submit(&mut self) -> Vec { let raw = std::mem::take(&mut self.input); + self.input_cursor = 0; let trimmed = raw.trim(); if trimmed.is_empty() { return Vec::new(); @@ -339,14 +466,45 @@ impl App { 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; } fn handle_dsl_failure(&mut self, command: &Command, error: &str) { warn!(verb = command.verb(), error, "dsl command failed"); + // Wrap the command portion in quotes so the message + // reads cleanly: "...failed: " rather than the + // command running into "failed: ..." with no break. self.note_error(format!( - "{} {} failed: {error}", + "\"{} {}\" failed: {error}", command.verb(), command.target_table() )); @@ -391,6 +549,26 @@ impl App { while self.output.len() > OUTPUT_CAPACITY { 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, 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] fn history_records_submitted_lines() { let mut app = App::new(); diff --git a/src/db.rs b/src/db.rs index 1ea17ce..e1b034e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -31,6 +31,8 @@ use rusqlite::Connection; use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; +use crate::dsl::action::ReferentialAction; +use crate::dsl::command::RelationshipSelector; use crate::dsl::ColumnSpec; use crate::dsl::types::Type; @@ -48,6 +50,33 @@ pub struct Database { pub struct TableDescription { pub name: String, pub columns: Vec, + /// Relationships where *this* table is the child (holds the + /// FK column referencing another table). + pub outbound_relationships: Vec, + /// Relationships where *this* table is the parent (some + /// other table's column references one of ours). + pub inbound_relationships: Vec, +} + +/// One end of a relationship as seen from the table being +/// described. +/// +/// Used for both outbound (this table is the child, holding the +/// FK column) and inbound (this table is the parent being +/// referenced) sides; the field meanings flip per side. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RelationshipEnd { + /// User-facing name of the relationship (auto-generated or + /// user-supplied at creation time). + pub name: String, + /// The other table involved. + pub other_table: String, + /// The column on the other table. + pub other_column: String, + /// The column on *this* table. + pub local_column: String, + pub on_delete: ReferentialAction, + pub on_update: ReferentialAction, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -157,6 +186,21 @@ enum Request { name: String, reply: oneshot::Sender>, }, + AddRelationship { + name: Option, + parent_table: String, + parent_column: String, + child_table: String, + child_column: String, + on_delete: ReferentialAction, + on_update: ReferentialAction, + create_fk: bool, + reply: oneshot::Sender>, + }, + DropRelationship { + selector: RelationshipSelector, + reply: oneshot::Sender, DbError>>, + }, } impl Database { @@ -234,6 +278,44 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + #[allow(clippy::too_many_arguments)] + pub async fn add_relationship( + &self, + name: Option, + parent_table: String, + parent_column: String, + child_table: String, + child_column: String, + on_delete: ReferentialAction, + on_update: ReferentialAction, + create_fk: bool, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::AddRelationship { + name, + parent_table, + parent_column, + child_table, + child_column, + on_delete, + on_update, + create_fk, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + + pub async fn drop_relationship( + &self, + selector: RelationshipSelector, + ) -> Result, DbError> { + let (reply, recv) = oneshot::channel(); + self.send(Request::DropRelationship { selector, reply }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + async fn send(&self, req: Request) -> Result<(), DbError> { self.inbox.send(req).await.map_err(|_| DbError::WorkerGone) } @@ -247,6 +329,7 @@ impl Database { /// for this metadata; this table is the in-database mirror that /// makes round-trip rendering work today. const META_TABLE: &str = "__rdbms_playground_columns"; +const REL_TABLE: &str = "__rdbms_playground_relationships"; fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch(&format!( @@ -256,6 +339,16 @@ fn configure_connection(conn: &Connection) -> Result<(), rusqlite::Error> { column_name TEXT NOT NULL,\n\ user_type TEXT NOT NULL,\n\ PRIMARY KEY (table_name, column_name)\n\ + ) STRICT;\n\ + CREATE TABLE IF NOT EXISTS {REL_TABLE} (\n\ + name TEXT NOT NULL UNIQUE,\n\ + parent_table TEXT NOT NULL,\n\ + parent_column TEXT NOT NULL,\n\ + child_table TEXT NOT NULL,\n\ + child_column TEXT NOT NULL,\n\ + on_delete TEXT NOT NULL,\n\ + on_update TEXT NOT NULL,\n\ + PRIMARY KEY (child_table, child_column)\n\ ) STRICT;" ))?; Ok(()) @@ -296,6 +389,32 @@ fn handle_request(conn: &Connection, req: Request) { Request::DescribeTable { name, reply } => { let _ = reply.send(do_describe_table(conn, &name)); } + Request::AddRelationship { + name, + parent_table, + parent_column, + child_table, + child_column, + on_delete, + on_update, + create_fk, + reply, + } => { + let _ = reply.send(do_add_relationship( + conn, + name.as_deref(), + &parent_table, + &parent_column, + &child_table, + &child_column, + on_delete, + on_update, + create_fk, + )); + } + Request::DropRelationship { selector, reply } => { + let _ = reply.send(do_drop_relationship(conn, &selector)); + } } } @@ -393,6 +512,20 @@ fn do_create_table( } fn do_drop_table(conn: &Connection, name: &str) -> Result<(), DbError> { + // Refuse the drop while any *other* table still has a + // relationship pointing at this one — dropping the parent + // would leave dangling FK constraints in the children. The + // user is told which relationships to drop first. + let inbound = read_relationships_inbound(conn, name)?; + if !inbound.is_empty() { + let names: Vec<&str> = inbound.iter().map(|r| r.name.as_str()).collect(); + return Err(DbError::Unsupported(format!( + "cannot drop `{name}`: it is referenced by relationship(s) [{}]. \ + Drop those relationships first.", + names.join(", ") + ))); + } + let ddl = format!("DROP TABLE {ident};", ident = quote_ident(name)); debug!(ddl = %ddl, "drop_table"); let tx = conn @@ -404,6 +537,13 @@ fn do_drop_table(conn: &Connection, name: &str) -> Result<(), DbError> { [name], ) .map_err(DbError::from_rusqlite)?; + // Outbound relationships are gone with the table — clean + // their metadata too. + tx.execute( + &format!("DELETE FROM {REL_TABLE} WHERE child_table = ?1;"), + [name], + ) + .map_err(DbError::from_rusqlite)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) } @@ -455,7 +595,7 @@ fn do_list_tables(conn: &Connection) -> Result, DbError> { "SELECT name FROM sqlite_schema \ WHERE type = 'table' \ AND name NOT LIKE 'sqlite_%' \ - AND name NOT LIKE '__rdbms_%' \ + AND substr(name, 1, 8) != '__rdbms_' \ ORDER BY name;", ) .map_err(DbError::from_rusqlite)?; @@ -469,6 +609,485 @@ fn do_list_tables(conn: &Connection) -> Result, DbError> { Ok(out) } +/// Internal full schema of a table, sufficient to regenerate +/// its `CREATE TABLE` statement during the rebuild dance. +#[derive(Debug, Clone)] +struct ReadSchema { + columns: Vec, + primary_key: Vec, + foreign_keys: Vec, +} + +#[derive(Debug, Clone)] +struct ReadColumn { + name: String, + sqlite_type: String, + notnull: bool, + primary_key: bool, + user_type: Option, +} + +#[derive(Debug, Clone)] +struct ReadForeignKey { + parent_table: String, + parent_column: String, + child_column: String, + on_delete: ReferentialAction, + on_update: ReferentialAction, +} + +fn read_schema(conn: &Connection, table: &str) -> Result { + // Columns + PK from pragma_table_info, joined with our user-type metadata. + let mut col_stmt = conn + .prepare(&format!( + "SELECT pti.name, pti.type, pti.\"notnull\", pti.pk, m.user_type \ + FROM pragma_table_info(?1) AS pti \ + LEFT JOIN {META_TABLE} AS m \ + ON m.table_name = ?1 AND m.column_name = pti.name \ + ORDER BY pti.cid;" + )) + .map_err(DbError::from_rusqlite)?; + let rows = col_stmt + .query_map([table], |row| { + let user_type_kw: Option = row.get(4)?; + let user_type = user_type_kw.and_then(|kw| kw.parse::().ok()); + Ok(ReadColumn { + name: row.get(0)?, + sqlite_type: row.get(1)?, + notnull: row.get::<_, i64>(2)? != 0, + primary_key: row.get::<_, i64>(3)? != 0, + user_type, + }) + }) + .map_err(DbError::from_rusqlite)?; + let mut columns = Vec::new(); + for row in rows { + columns.push(row.map_err(DbError::from_rusqlite)?); + } + if columns.is_empty() { + return Err(DbError::Sqlite { + message: format!("no such table: {table}"), + kind: SqliteErrorKind::NoSuchTable, + }); + } + let primary_key: Vec = columns + .iter() + .filter(|c| c.primary_key) + .map(|c| c.name.clone()) + .collect(); + + // Foreign keys from pragma_foreign_key_list. + let mut fk_stmt = conn + .prepare( + "SELECT \"table\", \"from\", \"to\", on_delete, on_update \ + FROM pragma_foreign_key_list(?1) \ + ORDER BY id, seq;", + ) + .map_err(DbError::from_rusqlite)?; + let fk_rows = fk_stmt + .query_map([table], |row| { + let on_delete_str: String = row.get(3)?; + let on_update_str: String = row.get(4)?; + Ok(ReadForeignKey { + parent_table: row.get(0)?, + child_column: row.get(1)?, + parent_column: row.get(2)?, + on_delete: parse_action_from_sqlite(&on_delete_str), + on_update: parse_action_from_sqlite(&on_update_str), + }) + }) + .map_err(DbError::from_rusqlite)?; + let mut foreign_keys = Vec::new(); + for row in fk_rows { + foreign_keys.push(row.map_err(DbError::from_rusqlite)?); + } + + Ok(ReadSchema { + columns, + primary_key, + foreign_keys, + }) +} + +fn parse_action_from_sqlite(s: &str) -> ReferentialAction { + // SQLite stores the action keywords in upper-case form + // ("CASCADE", "SET NULL", "NO ACTION", "RESTRICT"). + s.parse::() + .unwrap_or(ReferentialAction::NoAction) +} + +/// Generate the CREATE TABLE DDL from a `ReadSchema`. Used during +/// the rebuild dance. +fn schema_to_ddl(table: &str, schema: &ReadSchema) -> String { + let mut clauses: Vec = Vec::new(); + + // The single-column-INTEGER-PK case must be inline (`PRIMARY + // KEY` on the column itself) so SQLite gives it rowid-alias + // semantics. Compound or non-INTEGER PKs go to a table-level + // constraint. + let single_inline_pk = schema.primary_key.len() == 1 + && !schema.columns.is_empty() + && schema.columns[0].primary_key + && schema.primary_key[0] == schema.columns[0].name; + + for col in &schema.columns { + let mut clause = format!( + "{ident} {sqlite_type}", + ident = quote_ident(&col.name), + sqlite_type = col.sqlite_type, + ); + if col.notnull { + clause.push_str(" NOT NULL"); + } + if single_inline_pk && col.primary_key { + clause.push_str(" PRIMARY KEY"); + } + clauses.push(clause); + } + + if !single_inline_pk && !schema.primary_key.is_empty() { + let pk_idents: Vec = schema.primary_key.iter().map(|n| quote_ident(n)).collect(); + clauses.push(format!("PRIMARY KEY ({})", pk_idents.join(", "))); + } + + for fk in &schema.foreign_keys { + clauses.push(format!( + "FOREIGN KEY ({child}) REFERENCES {parent_table}({parent_col}) \ + ON DELETE {od} ON UPDATE {ou}", + child = quote_ident(&fk.child_column), + parent_table = quote_ident(&fk.parent_table), + parent_col = quote_ident(&fk.parent_column), + od = fk.on_delete.sql_clause(), + ou = fk.on_update.sql_clause(), + )); + } + + format!( + "CREATE TABLE {ident} ({clauses}) STRICT;", + ident = quote_ident(table), + clauses = clauses.join(", "), + ) +} + +/// The rebuild-table dance. Replaces `table` with a fresh table +/// constructed from `new_schema`, copying data column-by-name. +/// The caller is responsible for any metadata-table updates that +/// need to happen alongside the rebuild — those are passed in as +/// a closure and run inside the same transaction. +/// +/// Per the official SQLite ALTER-via-rebuild recipe: foreign-key +/// enforcement must be temporarily disabled at session level, +/// not just deferred, because the connection-level pragma is a +/// no-op inside transactions. +fn rebuild_table( + conn: &Connection, + table: &str, + old_schema: &ReadSchema, + new_schema: &ReadSchema, + metadata_updates: F, +) -> Result<(), DbError> +where + F: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>, +{ + // foreign_keys=OFF must be set *outside* a transaction. + conn.execute_batch("PRAGMA foreign_keys = OFF;") + .map_err(DbError::from_rusqlite)?; + + let result = (|| -> Result<(), DbError> { + let tx = conn + .unchecked_transaction() + .map_err(DbError::from_rusqlite)?; + + let temp_name = format!("__rdbms_rebuild_{table}"); + // Defensive: drop any leftover rebuild table from a + // previous failed attempt. + tx.execute_batch(&format!( + "DROP TABLE IF EXISTS {ident};", + ident = quote_ident(&temp_name) + )) + .map_err(DbError::from_rusqlite)?; + + let create_temp = schema_to_ddl(&temp_name, new_schema); + tx.execute_batch(&create_temp) + .map_err(DbError::from_rusqlite)?; + + // Copy data: only for columns that exist on both sides. + // Auto-created columns (e.g. via --create-fk) are absent + // from the old table, so they remain NULL in the new one. + let copy_cols: Vec<&str> = new_schema + .columns + .iter() + .filter(|c| old_schema.columns.iter().any(|oc| oc.name == c.name)) + .map(|c| c.name.as_str()) + .collect(); + if !copy_cols.is_empty() { + let cols_csv = copy_cols + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let copy_sql = format!( + "INSERT INTO {temp} ({cols}) SELECT {cols} FROM {orig};", + temp = quote_ident(&temp_name), + cols = cols_csv, + orig = quote_ident(table), + ); + tx.execute_batch(©_sql) + .map_err(DbError::from_rusqlite)?; + } + + tx.execute_batch(&format!( + "DROP TABLE {ident};", + ident = quote_ident(table) + )) + .map_err(DbError::from_rusqlite)?; + tx.execute_batch(&format!( + "ALTER TABLE {temp} RENAME TO {final_name};", + temp = quote_ident(&temp_name), + final_name = quote_ident(table), + )) + .map_err(DbError::from_rusqlite)?; + + metadata_updates(&tx)?; + + // Verify referential integrity before committing. Any + // returned rows mean a FK violation persisted. + let mut check = tx + .prepare("PRAGMA foreign_key_check;") + .map_err(DbError::from_rusqlite)?; + let mut rows = check.query([]).map_err(DbError::from_rusqlite)?; + if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? { + return Err(DbError::Sqlite { + message: format!( + "foreign-key check failed after rebuild of `{table}`; \ + existing data violates the new constraint" + ), + kind: SqliteErrorKind::Other, + }); + } + drop(rows); + drop(check); + + tx.commit().map_err(DbError::from_rusqlite)?; + Ok(()) + })(); + + // Always re-enable foreign_keys, even on error. + let pragma_result = conn + .execute_batch("PRAGMA foreign_keys = ON;") + .map_err(DbError::from_rusqlite); + + result.and(pragma_result) +} + +#[allow(clippy::too_many_arguments)] +fn do_add_relationship( + conn: &Connection, + name: Option<&str>, + parent_table: &str, + parent_column: &str, + child_table: &str, + child_column: &str, + on_delete: ReferentialAction, + on_update: ReferentialAction, + create_fk: bool, +) -> Result { + // 1. Read parent schema; verify the referenced column is a PK. + let parent_schema = read_schema(conn, parent_table)?; + let parent_col = parent_schema + .columns + .iter() + .find(|c| c.name == parent_column) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {parent_table}.{parent_column}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + if !parent_col.primary_key { + return Err(DbError::Unsupported(format!( + "column `{parent_table}.{parent_column}` is not a primary key. \ + Foreign keys must reference a primary key (UNIQUE-target FKs \ + land in a later iteration)." + ))); + } + + // 2. Read child schema; verify the FK column or auto-create. + let mut child_schema = read_schema(conn, child_table)?; + let needs_create_column = child_schema + .columns + .iter() + .all(|c| c.name != child_column); + if needs_create_column && !create_fk { + return Err(DbError::Unsupported(format!( + "column `{child_table}.{child_column}` does not exist. \ + Add it first, or use `--create-fk` to create it automatically." + ))); + } + + // 3. Determine child column type. Either the existing one's + // user_type, or the parent's fk_target_type for auto-create. + let parent_user_type = parent_col.user_type.ok_or_else(|| DbError::Unsupported( + "parent column has no user type metadata".to_string(), + ))?; + let expected_child_type = parent_user_type.fk_target_type(); + + if needs_create_column { + // Synthesise the column row for the new schema. + child_schema.columns.push(ReadColumn { + name: child_column.to_string(), + sqlite_type: expected_child_type.sqlite_strict_type().to_string(), + notnull: false, + primary_key: false, + user_type: Some(expected_child_type), + }); + } else { + // Validate type compatibility against the existing column. + let child_col = child_schema + .columns + .iter() + .find(|c| c.name == child_column) + .expect("checked above"); + let actual = child_col.user_type.ok_or_else(|| DbError::Unsupported( + "child column has no user type metadata".to_string(), + ))?; + if actual != expected_child_type { + return Err(DbError::Unsupported(format!( + "type mismatch: `{child_table}.{child_column}` is `{actual}` but \ + a foreign key referencing `{parent_table}.{parent_column}` \ + (`{parent_user_type}`) requires `{expected_child_type}`. \ + Either change the column type, or pick a different FK column." + ))); + } + } + + // 4. Determine relationship name (auto-gen or supplied) and + // check uniqueness against the metadata table. + let resolved_name = name.map_or_else( + // Auto-name follows the user-typed `from . + // to .` direction so the name reads as the + // grammar reads — see ADR-0013. + || format!("{parent_table}_{parent_column}_to_{child_table}_{child_column}"), + ToString::to_string, + ); + let collision: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM {REL_TABLE} WHERE name = ?1;"), + [&resolved_name], + |row| row.get(0), + ) + .map_err(DbError::from_rusqlite)?; + if collision > 0 { + return Err(DbError::Unsupported(format!( + "a relationship named `{resolved_name}` already exists. \ + Pick a different name or drop the existing one first." + ))); + } + + // 5. Build the new schema with the FK appended. + let mut new_schema = child_schema.clone(); + new_schema.foreign_keys.push(ReadForeignKey { + parent_table: parent_table.to_string(), + parent_column: parent_column.to_string(), + child_column: child_column.to_string(), + on_delete, + on_update, + }); + + // 6. Rebuild, with metadata updates inside the transaction. + let on_delete_kw = on_delete.keyword(); + let on_update_kw = on_update.keyword(); + let column_user_type_kw = expected_child_type.keyword(); + let resolved_name_for_meta = resolved_name.as_str(); + rebuild_table(conn, child_table, &child_schema, &new_schema, |tx| { + if needs_create_column { + tx.execute( + &format!( + "INSERT INTO {META_TABLE} (table_name, column_name, user_type) \ + VALUES (?1, ?2, ?3);" + ), + [child_table, child_column, column_user_type_kw], + ) + .map_err(DbError::from_rusqlite)?; + } + tx.execute( + &format!( + "INSERT INTO {REL_TABLE} \ + (name, parent_table, parent_column, child_table, child_column, on_delete, on_update) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);" + ), + [ + resolved_name_for_meta, + parent_table, + parent_column, + child_table, + child_column, + on_delete_kw, + on_update_kw, + ], + ) + .map_err(DbError::from_rusqlite)?; + Ok(()) + })?; + + do_describe_table(conn, child_table) +} + +fn do_drop_relationship( + conn: &Connection, + selector: &RelationshipSelector, +) -> Result, DbError> { + // Resolve to a single relationship row. + let resolved: Option<(String, String, String, String, String)> = match selector { + RelationshipSelector::Named { name } => conn + .query_row( + &format!( + "SELECT name, parent_table, parent_column, child_table, child_column \ + FROM {REL_TABLE} WHERE name = ?1;" + ), + [name], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + ) + .ok(), + RelationshipSelector::Endpoints { + parent_table, + parent_column, + child_table, + child_column, + } => conn + .query_row( + &format!( + "SELECT name, parent_table, parent_column, child_table, child_column \ + FROM {REL_TABLE} \ + WHERE parent_table = ?1 AND parent_column = ?2 \ + AND child_table = ?3 AND child_column = ?4;" + ), + [parent_table, parent_column, child_table, child_column], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), + ) + .ok(), + }; + let (rel_name, _parent_table, _parent_column, child_table, child_column) = + resolved.ok_or_else(|| DbError::Sqlite { + message: format!("no such relationship: {selector}"), + kind: SqliteErrorKind::Other, + })?; + + // Read child schema; build new schema without the FK. + let old_schema = read_schema(conn, &child_table)?; + let mut new_schema = old_schema.clone(); + new_schema.foreign_keys.retain(|fk| fk.child_column != child_column); + + rebuild_table(conn, &child_table, &old_schema, &new_schema, |tx| { + tx.execute( + &format!("DELETE FROM {REL_TABLE} WHERE name = ?1;"), + [rel_name.as_str()], + ) + .map_err(DbError::from_rusqlite)?; + Ok(()) + })?; + + Ok(Some(do_describe_table(conn, &child_table)?)) +} + fn do_describe_table(conn: &Connection, name: &str) -> Result { // `pragma_table_info` is a table-valued function in modern // SQLite; using it as a SELECT lets us bind the table name @@ -511,12 +1130,92 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result Result, DbError> { + let mut stmt = conn + .prepare(&format!( + "SELECT name, parent_table, parent_column, child_column, on_delete, on_update \ + FROM {REL_TABLE} \ + WHERE child_table = ?1 \ + ORDER BY name;" + )) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([table], |row| { + let on_delete: String = row.get(4)?; + let on_update: String = row.get(5)?; + Ok(RelationshipEnd { + name: row.get(0)?, + other_table: row.get(1)?, + other_column: row.get(2)?, + local_column: row.get(3)?, + on_delete: on_delete + .parse::() + .unwrap_or(ReferentialAction::NoAction), + on_update: on_update + .parse::() + .unwrap_or(ReferentialAction::NoAction), + }) + }) + .map_err(DbError::from_rusqlite)?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(DbError::from_rusqlite)?); + } + Ok(out) +} + +fn read_relationships_inbound( + conn: &Connection, + table: &str, +) -> Result, DbError> { + let mut stmt = conn + .prepare(&format!( + "SELECT name, child_table, child_column, parent_column, on_delete, on_update \ + FROM {REL_TABLE} \ + WHERE parent_table = ?1 \ + ORDER BY name;" + )) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([table], |row| { + let on_delete: String = row.get(4)?; + let on_update: String = row.get(5)?; + Ok(RelationshipEnd { + name: row.get(0)?, + other_table: row.get(1)?, + other_column: row.get(2)?, + local_column: row.get(3)?, + on_delete: on_delete + .parse::() + .unwrap_or(ReferentialAction::NoAction), + on_update: on_update + .parse::() + .unwrap_or(ReferentialAction::NoAction), + }) + }) + .map_err(DbError::from_rusqlite)?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(DbError::from_rusqlite)?); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -794,6 +1493,432 @@ mod tests { } } + // --- Relationship tests --- + + async fn customers_orders_setup(db: &Database) { + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial), col("Name", Type::Text)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.add_column("Orders".to_string(), "CustId".to_string(), Type::Int) + .await + .unwrap(); + } + + #[tokio::test] + async fn add_relationship_appears_outbound_on_child() { + let db = db(); + customers_orders_setup(&db).await; + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); + assert_eq!(orders.outbound_relationships.len(), 1); + let rel = &orders.outbound_relationships[0]; + assert_eq!(rel.local_column, "CustId"); + assert_eq!(rel.other_table, "Customers"); + assert_eq!(rel.other_column, "id"); + assert_eq!(rel.name, "Customers_id_to_Orders_CustId"); + } + + #[tokio::test] + async fn add_relationship_appears_inbound_on_parent() { + let db = db(); + customers_orders_setup(&db).await; + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); + assert_eq!(customers.inbound_relationships.len(), 1); + let rel = &customers.inbound_relationships[0]; + assert_eq!(rel.local_column, "id"); + assert_eq!(rel.other_table, "Orders"); + assert_eq!(rel.other_column, "CustId"); + } + + #[tokio::test] + async fn add_relationship_with_user_supplied_name() { + let db = db(); + customers_orders_setup(&db).await; + db.add_relationship( + Some("cust_orders".to_string()), + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::Cascade, + ReferentialAction::SetNull, + false, + ) + .await + .unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); + let rel = &orders.outbound_relationships[0]; + assert_eq!(rel.name, "cust_orders"); + assert_eq!(rel.on_delete, ReferentialAction::Cascade); + assert_eq!(rel.on_update, ReferentialAction::SetNull); + } + + #[tokio::test] + async fn add_relationship_with_create_fk_creates_the_column() { + let db = db(); + // Create child *without* the FK column. + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + true, // --create-fk + ) + .await + .unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); + // The auto-created FK column has user_type Int (Serial's + // fk_target_type), not Serial. + let cust = orders + .columns + .iter() + .find(|c| c.name == "CustId") + .expect("FK column auto-created"); + assert_eq!(cust.user_type, Some(Type::Int)); + assert_eq!(orders.outbound_relationships.len(), 1); + } + + #[tokio::test] + async fn add_relationship_without_create_fk_errors_when_column_missing() { + let db = db(); + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + let err = db + .add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); + } + + #[tokio::test] + async fn add_relationship_with_type_mismatch_errors_with_advice() { + let db = db(); + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + // Wrong type — text instead of int. + db.add_column("Orders".to_string(), "CustId".to_string(), Type::Text) + .await + .unwrap(); + + let err = db + .add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap_err(); + match err { + DbError::Unsupported(msg) => { + assert!(msg.contains("type mismatch"), "{msg}"); + assert!(msg.contains("text") && msg.contains("int"), "{msg}"); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[tokio::test] + async fn add_relationship_against_non_pk_errors() { + let db = db(); + // Customers has a non-PK column we'll try to point at. + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial), col("Name", Type::Text)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.add_column("Orders".to_string(), "CustName".to_string(), Type::Text) + .await + .unwrap(); + let err = db + .add_relationship( + None, + "Customers".to_string(), + "Name".to_string(), + "Orders".to_string(), + "CustName".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap_err(); + match err { + DbError::Unsupported(msg) => assert!(msg.contains("not a primary key"), "{msg}"), + other => panic!("unexpected error: {other:?}"), + } + } + + #[tokio::test] + async fn drop_relationship_by_name_clears_both_sides() { + let db = db(); + customers_orders_setup(&db).await; + db.add_relationship( + Some("cust_orders".to_string()), + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + db.drop_relationship(RelationshipSelector::Named { + name: "cust_orders".to_string(), + }) + .await + .unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); + assert!(orders.outbound_relationships.is_empty()); + assert!(customers.inbound_relationships.is_empty()); + } + + #[tokio::test] + async fn drop_relationship_by_endpoints_works() { + let db = db(); + customers_orders_setup(&db).await; + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + db.drop_relationship(RelationshipSelector::Endpoints { + parent_table: "Customers".to_string(), + parent_column: "id".to_string(), + child_table: "Orders".to_string(), + child_column: "CustId".to_string(), + }) + .await + .unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); + assert!(orders.outbound_relationships.is_empty()); + } + + #[tokio::test] + async fn drop_table_with_inbound_relationship_errors() { + let db = db(); + customers_orders_setup(&db).await; + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + let err = db.drop_table("Customers".to_string()).await.unwrap_err(); + match err { + DbError::Unsupported(msg) => { + assert!(msg.contains("referenced by"), "{msg}"); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[tokio::test] + async fn drop_child_table_cleans_relationship_metadata() { + let db = db(); + customers_orders_setup(&db).await; + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + // Dropping the child is allowed (no inbound relationships + // on Orders) and cleans the metadata. + db.drop_table("Orders".to_string()).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); + assert!(customers.inbound_relationships.is_empty()); + } + + #[tokio::test] + async fn add_relationship_with_duplicate_name_errors() { + let db = db(); + customers_orders_setup(&db).await; + db.add_column("Orders".to_string(), "OtherCust".to_string(), Type::Int) + .await + .unwrap(); + db.add_relationship( + Some("dup".to_string()), + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + let err = db + .add_relationship( + Some("dup".to_string()), + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "OtherCust".to_string(), + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap_err(); + match err { + DbError::Unsupported(msg) => assert!(msg.contains("already exists"), "{msg}"), + other => panic!("unexpected error: {other:?}"), + } + } + + #[tokio::test] + async fn rebuild_preserves_existing_data_through_relationship_change() { + let db = db(); + customers_orders_setup(&db).await; + // Direct INSERT through the underlying connection isn't + // exposed via the public API yet (C5). The point of this + // test is to verify the rebuild itself works on populated + // tables — we add a column with a default-able operation + // and then add a relationship to ensure the table survives. + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::Cascade, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + // After the rebuild, the original columns are still + // present with the right user types, and any extra + // metadata (Name on Customers) survives. + let customers = db.describe_table("Customers".to_string()).await.unwrap(); + let names: Vec<&str> = customers.columns.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, vec!["id", "Name"]); + let name_col = customers.columns.iter().find(|c| c.name == "Name").unwrap(); + assert_eq!(name_col.user_type, Some(Type::Text)); + } + #[tokio::test] async fn quoted_table_names_round_trip() { let db = db(); diff --git a/src/dsl/action.rs b/src/dsl/action.rs new file mode 100644 index 0000000..3f5b954 --- /dev/null +++ b/src/dsl/action.rs @@ -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 { + // Case-insensitive comparison; tolerates internal + // whitespace ("set null") by collapsing it. + let normalised: String = s + .split_whitespace() + .collect::>() + .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::>() + .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::().unwrap(), a); + } + } + + #[test] + fn parsing_is_case_insensitive_and_whitespace_tolerant() { + assert_eq!( + "Cascade".parse::().unwrap(), + ReferentialAction::Cascade + ); + assert_eq!( + "SET NULL".parse::().unwrap(), + ReferentialAction::SetNull + ); + assert_eq!( + "set null".parse::().unwrap(), + ReferentialAction::SetNull + ); + assert_eq!( + "No Action".parse::().unwrap(), + ReferentialAction::NoAction + ); + } + + #[test] + fn unknown_action_lists_alternatives() { + let err = "destroy".parse::().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"); + } +} diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 57ff7a2..b0d1f52 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -11,6 +11,7 @@ //! primary key`, junction-table convenience commands) emit into //! the same shape. +use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; /// A column at table-creation time: a name and a user-facing @@ -42,6 +43,64 @@ pub enum Command { column: String, 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 (`__to_`). + /// `create_fk` requests the child column be created + /// automatically with the appropriate type if it is missing. + AddRelationship { + name: Option, + 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 { @@ -52,16 +111,31 @@ impl Command { Self::CreateTable { .. } => "create table", Self::DropTable { .. } => "drop table", 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 - /// iteration operates on exactly one table. + /// The table whose structure most directly reflects the + /// 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] pub fn target_table(&self) -> &str { match self { - Self::CreateTable { name, .. } | Self::DropTable { name } => name, + Self::CreateTable { name, .. } + | Self::DropTable { name } + | Self::ShowTable { name } => name, 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, + }, } } } diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index 91d0c12..eb8871e 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -9,10 +9,12 @@ //! this module — that path uses `sqlparser-rs` and lives //! elsewhere when it lands. +pub mod action; pub mod command; pub mod parser; 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 types::Type; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 37b895e..2ce85bb 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -13,7 +13,8 @@ use chumsky::error::RichReason; 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; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -139,9 +140,178 @@ fn command_parser<'a>() .then_ignore(just(')').padded()) .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() + .then_ignore(end()) +} + +/// `add 1:n relationship [] from

. to . +/// [on delete ] [on update ] [--create-fk]`. +fn add_relationship_parser<'a>() +-> impl Parser<'a, &'a str, Command, extra::Err>> + 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 ` or +/// `drop relationship from

. to .`. +fn drop_relationship_parser<'a>() +-> impl Parser<'a, &'a str, Command, extra::Err>> + 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 `.` returning (table, column). +fn qualified_column<'a>() +-> impl Parser<'a, &'a str, (String, String), extra::Err>> + Clone { + identifier() + .then_ignore(just('.').padded()) + .then(identifier()) +} + +/// Optional `on delete ` and/or `on update `, +/// in either order. Default to `NoAction` when omitted. +fn referential_clauses<'a>() -> impl Parser< + 'a, + &'a str, + (ReferentialAction, ReferentialAction), + extra::Err>, +> + 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::>() + .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>> + 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>> + Clone { + just("--create-fk") .padded() - .then_ignore(end()) + .or_not() + .map(|opt| opt.is_some()) } /// Parse the optional `with pk []` clause that may follow @@ -471,6 +641,207 @@ mod tests { 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] fn identifier_allows_underscores_and_digits_after_start() { assert_eq!( diff --git a/src/runtime.rs b/src/runtime.rs index bb6db88..dddf63f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -68,7 +68,7 @@ async fn run_loop( seed_initial_tables(&database, &event_tx).await; terminal - .draw(|f| ui::render(&app, &theme, f)) + .draw(|f| ui::render(&mut app, &theme, f)) .context("initial draw")?; info!("entering main event loop"); @@ -87,7 +87,7 @@ async fn run_loop( } } terminal - .draw(|f| ui::render(&app, &theme, f)) + .draw(|f| ui::render(&mut app, &theme, f)) .context("redraw")?; if should_quit { break; @@ -170,6 +170,38 @@ async fn execute_command( .await .map(Some) .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), } } diff --git a/src/ui.rs b/src/ui.rs index 850fef4..d11df10 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -17,7 +17,13 @@ use crate::mode::Mode; use crate::theme::Theme; /// 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(); 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]); } -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() .direction(Direction::Vertical) .constraints([ @@ -105,7 +111,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec 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() .borders(Borders::ALL) .border_type(BorderType::Rounded) @@ -123,15 +129,25 @@ fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Re vertical: 1, }); - // Show the most recent lines that fit. The output buffer is - // append-only, so taking from the back gives "most recent". + // Show a window of the buffer ending `output_scroll` lines + // 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; + 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> = app .output .iter() - .rev() - .take(visible) - .rev() + .skip(start) + .take(end - start) .map(|line| render_output_line(line, theme)) .collect(); @@ -193,11 +209,29 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec .title(title) .style(Style::default().bg(theme.bg).fg(theme.fg)); - // Cursor block: the character at the cursor position is rendered - // inverted so it is visible without enabling a real terminal cursor. + // Cursor block: render the character at the cursor position + // 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![ - Span::styled(app.input.as_str(), Style::default().fg(theme.fg)), - Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)), + Span::styled(before, Style::default().fg(theme.fg)), + 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); frame.render_widget(paragraph, area); @@ -273,7 +307,7 @@ mod tests { use ratatui::Terminal; 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 mut terminal = Terminal::new(backend).expect("create terminal"); terminal @@ -292,17 +326,17 @@ mod tests { #[test] fn dark_theme_default_view_snapshot() { - let app = App::new(); + let mut app = App::new(); 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); } #[test] fn light_theme_default_view_snapshot() { - let app = App::new(); + let mut app = App::new(); 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); } @@ -311,7 +345,7 @@ mod tests { let mut app = App::new(); app.mode = Mode::Advanced; 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); } @@ -323,7 +357,7 @@ mod tests { let mut app = App::new(); app.input.push_str(": sel"); 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); } @@ -355,6 +389,8 @@ mod tests { primary_key: false, }, ], + outbound_relationships: Vec::new(), + inbound_relationships: Vec::new(), }; app.current_table = Some(desc); // Mirror what the App writes when a DSL command succeeds. @@ -380,7 +416,7 @@ mod tests { }); 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); } } diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index f46a716..b6576fd 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -12,8 +12,8 @@ use ratatui::backend::TestBackend; use rdbms_playground::action::Action; use rdbms_playground::app::{App, OutputKind}; -use rdbms_playground::db::{ColumnDescription, TableDescription}; -use rdbms_playground::dsl::{ColumnSpec, Command, Type}; +use rdbms_playground::db::{ColumnDescription, RelationshipEnd, TableDescription}; +use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type}; use rdbms_playground::event::AppEvent; use rdbms_playground::mode::Mode; use rdbms_playground::theme::Theme; @@ -40,7 +40,7 @@ fn submit(app: &mut App) -> Vec { 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 mut terminal = Terminal::new(backend).expect("create terminal"); terminal @@ -63,7 +63,7 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() { let theme = Theme::dark(); 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!( pre_render.contains("create table Customers"), "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"); - let post_render = rendered_text(&app, &theme, 80, 24); + let post_render = rendered_text(&mut app, &theme, 80, 24); assert!( post_render.contains("running:"), "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"); let actions = submit(&mut app); assert!(actions.is_empty()); - let rendered = rendered_text(&app, &theme, 80, 24); + let rendered = rendered_text(&mut app, &theme, 80, 24); assert!( rendered.contains("parse error"), "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 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("ADVANCED")); @@ -116,7 +116,7 @@ fn mode_switch_changes_label_and_subsequent_echoes() { submit(&mut app); 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")); type_str(&mut app, "select 1"); @@ -162,9 +162,9 @@ fn quit_command_returns_quit_action() { #[test] fn rendering_works_at_minimum_useful_size() { // 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 _ = rendered_text(&app, &theme, 40, 12); + let _ = rendered_text(&mut app, &theme, 40, 12); } #[test] @@ -174,14 +174,14 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() { // No `:` yet — prompt shows SIMPLE. 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("Advanced:")); // Reset and type `:` first — prompt should flip immediately. app.input.clear(); type_str(&mut app, ":"); - let after_colon = rendered_text(&app, &theme, 80, 24); + let after_colon = rendered_text(&mut app, &theme, 80, 24); assert!( after_colon.contains("Advanced:"), "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() { 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("Advanced:")); } @@ -203,14 +203,14 @@ fn status_bar_lists_quit_and_submit_in_all_modes() { let mut app = App::new(); 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("Ctrl-C"), "status bar lists Ctrl-C"); assert!(simple.contains("mode advanced")); type_str(&mut app, "mode advanced"); 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("Ctrl-C")); assert!(advanced.contains("mode simple")); @@ -239,6 +239,8 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription { primary_key: *pk, }) .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.current_table, Some(desc)); - let rendered = rendered_text(&app, &theme, 80, 24); + let rendered = rendered_text(&mut app, &theme, 80, 24); assert!( rendered.contains("Customers"), "items panel should list Customers:\n{rendered}" @@ -320,7 +322,7 @@ fn add_column_flow_updates_structure_view() { description: Some(updated.clone()), }); 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")); } @@ -349,11 +351,118 @@ fn drop_table_flow_clears_items_list() { assert!(app.tables.is_empty()); 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("[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] fn dsl_failure_shows_friendly_error_in_output() { let mut app = App::new(); @@ -365,7 +474,7 @@ fn dsl_failure_shows_friendly_error_in_output() { }, 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!( rendered.contains("Ghost"), "error should mention the table:\n{rendered}"