From 305e5083d51f1d431c1aa789b816dc0f08b08b51 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 7 May 2026 16:33:25 +0000 Subject: [PATCH] INSERT/UPDATE/DELETE + value model + auto-show, with polish DSL data operations (ADR-0014): - insert into T [(cols)] values (vals); short form insert into T (vals) omits values keyword for friendlier syntax. - update T set ... where col=val | --all-rows; delete from T where col=val | --all-rows; show data T. - Value AST (Number/Text/Bool/Null) with per-column-type validation in the executor: int/real/decimal/bool/date/ datetime/shortid each accept a documented literal shape and produce friendly format errors naming the column. - INSERT short form fills non-auto-generated columns in schema order; auto-fills serial via SQLite and shortid via the new generator (T2). - `add column [to table] T: c (type)` -- `to table` now optional. Database: - insert/update/delete via prepared statements with bound rusqlite::types::Value parameters. - InsertResult/UpdateResult/DeleteResult: writes return rows_affected plus the affected row(s) only (not the whole table), so users see exactly what changed. - INSERT shows the just-inserted row via last_insert_rowid. - UPDATE captures matching rowids up-front and fetches them post-update -- works even if the UPDATE changed the WHERE column. - DELETE reports per-relationship cascade effects by row- count diffing inbound child tables; UPDATE-side cascades are not yet detected (would need value diffing). - query_data formats cells (booleans true/false, NULLs as None). FK error enrichment: - Now lists both outbound (INSERT/UPDATE relevance) and inbound (DELETE/UPDATE on parent relevance) FKs from the metadata, so RESTRICT errors point at the children blocking the delete. - RelationshipSelector has a proper Display impl -- "no such relationship" reads cleanly. Relationship display: - target_table for AddRelationship/DropRelationship now returns the parent (1-side); structure rendering after add/drop shows that side's "Referenced by:" entry, matching the `from ` direction of the command. - [ok] summary uses display_subject so relationship commands show both endpoints (`from P.col to C.col`) rather than a single misleading table name. - Auto-name format `__to__` (matches the from..to direction). Output rendering and scrolling: - Wrap-aware scroll: renderer reports both visible-row count and total wrapped-row count to App; scroll math caps against actual displayable rows. Long lines wrap; the bottom line is always reachable; PageUp/PageDown work correctly even after paging past the buffer top. - Multi-line messages (FK error enrichment, cascade summary) split into single-line OutputLines at creation time so wrap/scroll math agree. Runtime / events: - New AppEvent variants for Insert/Update/Delete success carrying typed result structs; DslDataSucceeded reserved for show-data queries. Docs: - ADR-0014 covers data-op grammar, value model, --all-rows safety, auto-show. - requirements.md: C5 done, T2 done, V2 partial (basic data view), V5 partial (show data added). New entries: C5a complex WHERE expressions; H1 progress note for FK enrichment; H1a (strong syntax-help in parse errors). Tests: 200 passing (183 lib + 17 integration), 0 skipped. Includes parser, type-validation, DB write/read, FK-failure enrichment, cascade-delete propagation, focused-auto-show behaviour, scroll-cap invariants. Clippy clean with nursery enabled. --- Cargo.lock | 45 +- Cargo.toml | 1 + .../0014-data-operations-and-value-model.md | 172 ++++ docs/adr/README.md | 1 + docs/requirements.md | 39 +- src/app.rs | 235 ++++- src/db.rs | 968 +++++++++++++++++- src/dsl/command.rs | 88 +- src/dsl/mod.rs | 5 +- src/dsl/parser.rs | 381 ++++++- src/dsl/shortid.rs | 115 +++ src/dsl/value.rs | 390 +++++++ src/event.rs | 16 +- src/runtime.rs | 73 +- src/ui.rs | 76 +- tests/walking_skeleton.rs | 142 ++- 16 files changed, 2638 insertions(+), 109 deletions(-) create mode 100644 docs/adr/0014-data-operations-and-value-model.md create mode 100644 src/dsl/shortid.rs create mode 100644 src/dsl/value.rs diff --git a/Cargo.lock b/Cargo.lock index b201af2..17fb454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chumsky" version = "0.13.0" @@ -195,6 +206,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -518,6 +538,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -990,7 +1011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.6", ] [[package]] @@ -1105,7 +1126,18 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1114,6 +1146,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -1209,6 +1247,7 @@ dependencies = [ "futures-util", "insta", "pretty_assertions", + "rand 0.10.1", "ratatui", "rusqlite", "thiserror 2.0.18", @@ -1376,7 +1415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] diff --git a/Cargo.toml b/Cargo.toml index 4c5e235..eaeb229 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ anyhow = "1.0.102" chumsky = "0.13.0" crossterm = { version = "0.29.0", features = ["event-stream"] } futures-util = "0.3.32" +rand = "0.10.1" ratatui = "0.30.0" rusqlite = { version = "0.39.0", features = ["bundled"] } thiserror = "2.0.18" diff --git a/docs/adr/0014-data-operations-and-value-model.md b/docs/adr/0014-data-operations-and-value-model.md new file mode 100644 index 0000000..2efeb50 --- /dev/null +++ b/docs/adr/0014-data-operations-and-value-model.md @@ -0,0 +1,172 @@ +# ADR-0014: Data operations, value literals, and the auto-show pattern + +## Status + +Accepted + +## Context + +Schema operations (ADRs 0002, 0005, 0011, 0013) gave us tables, +columns, and relationships. Without INSERT / UPDATE / DELETE, +foreign-key behaviour is observable but not demonstrable — a +learner can't yet trigger a CASCADE or watch a constraint catch +a bad write. C5 closes that gap. + +Several coupled questions: + +- **Value literals.** How does the user write `'2025-01-15'` for + a date column versus `42` for an int column? What gets + validated where? +- **Safe defaults for destructive operations.** UPDATE and + DELETE without WHERE are classic foot-guns. +- **Auto-generation of `shortid`.** T2 commits to client-side + generation at insert time, which now becomes load-bearing. +- **FK error clarity.** SQLite reports `FOREIGN KEY constraint + failed` with no detail; pedagogically that's nearly useless. +- **Showing data back to the user.** Without a `SELECT`-like + surface, the user has no way to see what changed. + +## Decision + +### Grammar + +``` +insert into [(, ...)] values (, ...) +update
set =[, =...] (where = | --all-rows) +delete from
(where = | --all-rows) +show data
+``` + +- **INSERT short form** (`insert into T values (...)`): values + apply to non-auto-generated columns in schema declaration + order. Serial columns are filled by SQLite; shortid columns + are auto-generated by the executor. +- **INSERT long form** (with explicit column list): user + controls exactly which columns receive values; auto-generated + columns the user didn't list are still auto-filled. +- **WHERE clause** is required for UPDATE and DELETE by default. + The `--all-rows` flag is the explicit opt-in to unfiltered + operations, following ADR-0009 (`--` reserved for opt-in + flags). Specifying both WHERE and `--all-rows` is a parse + error. +- **WHERE this iteration** is exactly `=`. Richer + WHERE expressions (AND/OR/comparison/LIKE) are deferred — they + are tracked as a future iteration and are intended as the + bridge from DSL into real SQL fluency. +- **`show data
`** joins the V5 show-family (with + `show table `); auto-show after writes (below) means + most users won't need to call it explicitly. + +### Value literals + +The parser produces a small `Value` AST (`Number(String)`, +`Text(String)`, `Bool(bool)`, `Null`). Per-column-type validation +lives in the executor where the schema is known: + +| User-facing type | Accepted literal | +|------------------|-------------------------------------------------------------| +| `text` | single-quoted string `'hello'` (`''` escapes a quote) | +| `int` | integer literal `42`, `-7` | +| `real` | numeric literal `3.14`, `-0.5` | +| `decimal` | numeric literal; stored as text to preserve precision | +| `bool` | `true` / `false` | +| `date` | quoted `'YYYY-MM-DD'` (validated) | +| `datetime` | quoted `'YYYY-MM-DDTHH:MM:SS[.fff][Z|±HH:MM]'` (validated) | +| `blob` | DSL literal not supported this iteration | +| `serial` | normally omitted (auto-fill); explicit integer accepted | +| `shortid` | normally omitted (auto-generated); explicit base58 10–12 | +| `null` | keyword `null` | + +Validation produces friendly errors that name the column and +expected shape — e.g. "column `Name` expects a quoted string for +`text`, got number". + +### Auto-generation for `shortid` + +When an INSERT (in either form) does not provide a value for a +`shortid` column, the executor calls the shortid generator and +fills in a 10-character base58 value (no `0`/`O`/`I`/`l`). +Explicit values are accepted but validated against the same +alphabet and length range (10–12 chars). The `rand` crate is +the source of randomness. + +### FK error enrichment + +SQLite reports `FOREIGN KEY constraint failed` without naming +the offending constraint or value. The executor catches this +class of error and appends the table's outbound relationships +(via the metadata table from ADR-0013) to the message: + +``` +FOREIGN KEY constraint failed. Foreign keys on this table: + - Orders.CustId → Customers.id +Check that each referenced value exists in the parent table. +``` + +Identifying the *exact* offending row is left to the H1 friendly +error layer when that lands. + +### Auto-show after writes + +INSERT, UPDATE, and DELETE successfully completing fetch the +target table's full data and emit a `DslDataSucceeded` event +carrying both `rows_affected: Some(n)` and the data view. The +App renders both. Users see the result immediately without +needing a follow-up `show data` command. + +`show data
` follows the same path with +`rows_affected: None`. + +The auto-show convention is reserved for DSL data ops. When +advanced-mode SQL lands (Q1), arbitrary `SELECT` statements may +opt out — large result sets shouldn't be implicitly inserted into +the session log. + +### Tabular rendering + +The data view is rendered as simple aligned-column text: + +``` + id | Name | Email + ----+-----------+----------------- + 1 | Alice | a@b.com + 2 | Bob | (null) +``` + +Pretty box-drawing renderings (with truncation, scroll +indicators, wide-table handling) are deferred to V4. NULL cells +render as `(null)` to be explicitly visible; booleans render as +`true`/`false` despite their integer storage. + +## Consequences + +- C5 is satisfied: INSERT/UPDATE/DELETE operate end-to-end with + validation, auto-generation, FK enforcement, and visible + feedback. +- T2 is satisfied: shortid auto-generation runs on insert. +- V2 partial: a usable tabular data view exists, with the + pretty-rendering iteration still ahead. +- V5 partial: `show data` joins `show table` in the show family. +- H1 partial: FK-failure messages are enriched without + introducing the full friendly-error layer. +- The `--all-rows` opt-in convention is now established for + destructive-without-filter operations — future commands of the + same shape (`drop relationship --cascade`?) follow the + pattern. +- The runtime's `CommandOutcome` enum is extended with a `Data` + variant. New data-emitting commands plug in there without + reshaping the dispatch. +- Complex WHERE expressions (AND/OR/comparison/LIKE), bulk + INSERT, ORDER BY, LIMIT, JOIN, and SELECT in advanced mode are + explicitly out of scope for this iteration; richer DSL WHERE + is the bridge iteration toward Q1's full SQL handling. + +## See also + +- ADR-0005 (column types — value-literal mappings here mirror + the storage choices) +- ADR-0009 (DSL command syntax conventions — `--` flag rule) +- ADR-0011 (FK column type compatibility — used during the + validation that runs before writes) +- ADR-0013 (relationships and rebuild-table — the FK metadata + used by the error-enrichment path) diff --git a/docs/adr/README.md b/docs/adr/README.md index 73d52b0..8b0b8fb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,3 +19,4 @@ This directory contains the project's ADRs, recorded per - [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) +- [ADR-0014 — Data operations, value literals, and the auto-show pattern](0014-data-operations-and-value-model.md) diff --git a/docs/requirements.md b/docs/requirements.md index 8539199..a292fcf 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -132,7 +132,18 @@ against it. - [ ] **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. -- [ ] **C5** Data operations: insert / update / delete via DSL. +- [x] **C5** Data operations: insert / update / delete via DSL. + *(ADR-0014. INSERT short and long forms, UPDATE/DELETE with + required WHERE plus `--all-rows` opt-in, `show data `, + per-column-type value-literal validation, FK enforcement + with metadata-driven error enrichment, auto-show after + writes. Bulk insert, complex WHERE expressions, and SELECT + in advanced mode are explicitly tracked separately — see + C5a below.)* +- [~] **C5a** Complex WHERE expressions (AND/OR/comparison + operators/LIKE) for UPDATE/DELETE/show-data filtering. Tracks + the natural progression from DSL into real SQL fluency that + motivates the playground; design and ADR pending. ## SQL handling @@ -170,10 +181,11 @@ against it. `int`, `real`, `decimal`, `bool`, `date`, `datetime`, `blob`, `serial`, `shortid`. *(Mapping to SQLite STRICT covered by ADR-0005; FK target type rule by ADR-0011.)* -- [ ] **T2** `shortid` generation: base58, 10–12 characters, +- [x] **T2** `shortid` generation: base58, 10–12 characters, omits ambiguous characters; generated client-side at insert. - *(Type exists; insert-time generation arrives with the data - insertion path.)* + *(Implemented per ADR-0014; auto-fills omitted shortid + columns and validates user-supplied values against the same + alphabet and length range.)* - [ ] **T3** Compound primary keys handled end-to-end (DSL, storage, display, FK reference). *(Progress: DSL grammar (`with pk a:int,b:int`), storage, and @@ -193,6 +205,10 @@ against it. see V4 for the broader direction.)* - [ ] **V2** SQL query results render as a dynamic table view in the output pane, with multiple result tabs supported. + *(Progress: a basic aligned-column data view is rendered for + `show data` and after every write (ADR-0014). Pretty + box-drawing tables with truncation/scroll handling, plus + multi-tab support, remain in V4 territory.)* - [~] **V3** Full ER-diagram export (whole-database graph, viewed outside the TUI) — low priority; design and ADR pending. - [~] **V4** Output panel as a *scrollable per-session log* with @@ -210,7 +226,7 @@ against it. 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; + ` and `show data
` implemented; `show tables`, `show relationships`, etc. pending.)* ## Project lifecycle (per ADR-0004) @@ -281,6 +297,19 @@ against it. - [ ] **H1** Friendly error-rewriting layer translates SQLite error messages into learner-friendly equivalents. + *(Progress: foreign-key constraint failures are enriched + with both inbound and outbound relationship listings (so + RESTRICT errors point at the children that still reference + this table); full SQL → English translation pending.)* +- [ ] **H1a** Strong syntax-help in parse errors. When the user + types something near-correct (e.g. `insert into T ('Oli')` — + forgotten `values`; or `update T set x=1` — missing WHERE), + the error should *name the missing keyword or clause* rather + than just point at the unexpected character. This is a + separate effort from H1 (which targets database errors); it + targets parser errors. Pending — multiple targeted fixes + shipping piecemeal so far (e.g. `values` becoming optional in + INSERT removes one such case). - [ ] **H2** `hint` provides contextual help for the current input or the most recent error. - [ ] **H3** `help` provides general reference and per-command diff --git a/src/app.rs b/src/app.rs index d3c1f26..9ce97d1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use tracing::{trace, warn}; use crate::action::Action; -use crate::db::TableDescription; +use crate::db::{ + CascadeEffect, DataResult, DeleteResult, InsertResult, TableDescription, UpdateResult, +}; use crate::dsl::{Command, ParseError, parse_command}; use crate::event::AppEvent; use crate::mode::Mode; @@ -94,6 +96,11 @@ pub struct App { /// the visible window off the top of the buffer and shrink /// what the user sees. pub last_output_visible: usize, + /// The most recent total *wrapped* row count of the output + /// panel — counted in display rows after wrapping, not in + /// logical OutputLines. Required for accurate scroll capping + /// when long lines wrap to multiple display rows. + pub last_output_total_wrapped: usize, } const PAGE_SCROLL_LINES: usize = 5; @@ -122,18 +129,22 @@ impl App { history_draft: None, output_scroll: 0, last_output_visible: 0, + last_output_total_wrapped: 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) { + /// Called by the renderer with the current output-panel + /// dimensions (row count + total wrapped-row count for the + /// current buffer) so subsequent scroll input is capped + /// correctly. Without `total_wrapped`, scroll math would + /// incorrectly assume one logical line = one display row. + pub const fn note_output_viewport(&mut self, visible_rows: usize, total_wrapped_rows: usize) { self.last_output_visible = visible_rows; + self.last_output_total_wrapped = total_wrapped_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); + let max = total_wrapped_rows.saturating_sub(visible_rows); if self.output_scroll > max { self.output_scroll = max; } @@ -166,6 +177,22 @@ impl App { self.handle_dsl_success(&command, description); Vec::new() } + AppEvent::DslDataSucceeded { command, data } => { + self.handle_dsl_query_success(&command, &data); + Vec::new() + } + AppEvent::DslInsertSucceeded { command, result } => { + self.handle_dsl_insert_success(&command, &result); + Vec::new() + } + AppEvent::DslUpdateSucceeded { command, result } => { + self.handle_dsl_update_success(&command, &result); + Vec::new() + } + AppEvent::DslDeleteSucceeded { command, result } => { + self.handle_dsl_delete_success(&command, &result); + Vec::new() + } AppEvent::DslFailed { command, error } => { self.handle_dsl_failure(&command, &error); Vec::new() @@ -447,7 +474,7 @@ impl App { } fn handle_dsl_success(&mut self, command: &Command, description: Option) { - let summary = format!("[ok] {} {}", command.verb(), command.target_table()); + let summary = format!("[ok] {} {}", command.verb(), command.display_subject()); self.note_system(summary); if let Some(desc) = description.as_ref() { self.note_system(format!(" {}", desc.name)); @@ -498,6 +525,50 @@ impl App { self.current_table = description; } + fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) { + let summary = format!("[ok] {} {}", command.verb(), command.display_subject()); + self.note_system(summary); + for line in render_data_view(data) { + self.note_system(line); + } + } + + fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) { + self.note_system(format!( + "[ok] {} {}", + command.verb(), + command.display_subject() + )); + self.note_system(format!(" {} row(s) inserted", result.rows_affected)); + for line in render_data_view(&result.data) { + self.note_system(line); + } + } + + fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) { + self.note_system(format!( + "[ok] {} {}", + command.verb(), + command.display_subject() + )); + self.note_system(format!(" {} row(s) updated", result.rows_affected)); + for line in render_data_view(&result.data) { + self.note_system(line); + } + } + + fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) { + self.note_system(format!( + "[ok] {} {}", + command.verb(), + command.display_subject() + )); + self.note_system(format!(" {} row(s) deleted", result.rows_affected)); + for effect in &result.cascade { + self.note_system(render_cascade_effect(effect)); + } + } + 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 @@ -506,7 +577,7 @@ impl App { self.note_error(format!( "\"{} {}\" failed: {error}", command.verb(), - command.target_table() + command.display_subject() )); } @@ -529,19 +600,34 @@ impl App { } fn note_system(&mut self, text: impl Into) { - self.push_output(OutputLine { - text: text.into(), - kind: OutputKind::System, - mode_at_submission: self.mode, - }); + self.push_multiline(text.into(), OutputKind::System); } fn note_error(&mut self, text: impl Into) { - self.push_output(OutputLine { - text: text.into(), - kind: OutputKind::Error, - mode_at_submission: self.mode, - }); + self.push_multiline(text.into(), OutputKind::Error); + } + + /// Push possibly-multi-line `text` as a sequence of single-line + /// `OutputLine`s. Keeping one display row per `OutputLine` is + /// what makes the scroll-position math (line count = display + /// rows) accurate; the renderer therefore truncates rather + /// than wraps long lines. + fn push_multiline(&mut self, text: String, kind: OutputKind) { + if text.is_empty() { + self.push_output(OutputLine { + text, + kind, + mode_at_submission: self.mode, + }); + return; + } + for line in text.split('\n') { + self.push_output(OutputLine { + text: line.to_string(), + kind, + mode_at_submission: self.mode, + }); + } } fn push_output(&mut self, line: OutputLine) { @@ -556,13 +642,12 @@ impl App { } 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. + // Cap at `total_wrapped - visible` (display rows, not + // logical lines) so the topmost visible chunk is the + // first `visible` rendered rows; going past that would + // shrink the view by sliding the window off the top. let max = self - .output - .len() + .last_output_total_wrapped .saturating_sub(self.last_output_visible.max(1)); self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max); } @@ -579,6 +664,94 @@ fn parse_error_message(err: &ParseError) -> String { } } +fn render_cascade_effect(effect: &CascadeEffect) -> String { + use crate::dsl::ReferentialAction; + let what = match effect.action { + ReferentialAction::Cascade => "deleted", + ReferentialAction::SetNull => "had FK set to null", + ReferentialAction::Restrict | ReferentialAction::NoAction => "blocked", + }; + format!( + " related: {} row(s) {} in `{}` for relationship `{}` (on delete {})", + effect.rows_changed, + what, + effect.child_table, + effect.relationship_name, + effect.action, + ) +} + +/// Render a data result as a sequence of aligned-column text +/// lines suitable for the output panel. Pretty box-drawing +/// rendering is V4 territory; this version uses simple +/// pipe-and-dash separators. +fn render_data_view(data: &DataResult) -> Vec { + let header = data.columns.clone(); + let body: Vec> = data + .rows + .iter() + .map(|row| { + row.iter() + .map(|cell| { + cell.as_ref() + .map_or_else(|| "(null)".to_string(), Clone::clone) + }) + .collect() + }) + .collect(); + + // Column widths = max(header, all cells) per column. + let mut widths: Vec = header.iter().map(String::len).collect(); + for row in &body { + for (i, cell) in row.iter().enumerate() { + if i < widths.len() && cell.chars().count() > widths[i] { + widths[i] = cell.chars().count(); + } + } + } + + let mut out: Vec = Vec::with_capacity(body.len() + 3); + out.push(format!(" {}", join_padded(&header, &widths))); + out.push(format!(" {}", separator_row(&widths))); + if body.is_empty() { + out.push(" (no rows)".to_string()); + } else { + for row in &body { + out.push(format!(" {}", join_padded(row, &widths))); + } + } + out +} + +fn join_padded(cells: &[String], widths: &[usize]) -> String { + let mut s = String::new(); + for (i, cell) in cells.iter().enumerate() { + if i > 0 { + s.push_str(" | "); + } + let w = widths.get(i).copied().unwrap_or(0); + s.push_str(cell); + let pad = w.saturating_sub(cell.chars().count()); + for _ in 0..pad { + s.push(' '); + } + } + s +} + +fn separator_row(widths: &[usize]) -> String { + let mut s = String::new(); + for (i, w) in widths.iter().enumerate() { + if i > 0 { + s.push_str("-+-"); + } + for _ in 0..*w { + s.push('-'); + } + } + s +} + #[cfg(test)] mod tests { use super::*; @@ -1033,6 +1206,8 @@ mod tests { for i in 0..30 { app.note_system(format!("line{i}")); } + // Simulate a render establishing 10 visible / 30 wrapped. + app.note_output_viewport(10, 30); assert_eq!(app.output_scroll, 0); app.update(key(KeyCode::PageUp)); assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES); @@ -1044,6 +1219,7 @@ mod tests { for i in 0..30 { app.note_system(format!("line{i}")); } + app.note_output_viewport(10, 30); for _ in 0..3 { app.update(key(KeyCode::PageUp)); } @@ -1060,6 +1236,7 @@ mod tests { for i in 0..30 { app.note_system(format!("line{i}")); } + app.note_output_viewport(10, 30); app.update(key(KeyCode::PageUp)); assert!(app.output_scroll > 0); // Any new output line snaps the scroll back to bottom so @@ -1091,13 +1268,15 @@ mod tests { for i in 0..30 { app.note_system(format!("line{i}")); } - // Simulate a render reporting 10 visible rows. - app.note_output_viewport(10); + // Simulate a render reporting 10 visible rows over a + // 30-row wrapped buffer (every line fits in one row in + // this test). + app.note_output_viewport(10, 30); // 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. + // Cap should be at total_wrapped - visible = 30 - 10 = 20. assert_eq!(app.output_scroll, 20); } @@ -1111,7 +1290,7 @@ mod tests { app.note_system(format!("line{i}")); } app.output_scroll = 100; - app.note_output_viewport(10); + app.note_output_viewport(10, 30); assert_eq!(app.output_scroll, 20); } diff --git a/src/db.rs b/src/db.rs index e1b034e..1905e07 100644 --- a/src/db.rs +++ b/src/db.rs @@ -32,9 +32,11 @@ use tokio::sync::{mpsc, oneshot}; use tracing::{debug, info, warn}; use crate::dsl::action::ReferentialAction; -use crate::dsl::command::RelationshipSelector; +use crate::dsl::command::{RelationshipSelector, RowFilter}; use crate::dsl::ColumnSpec; +use crate::dsl::shortid; use crate::dsl::types::Type; +use crate::dsl::value::{Bound, Value, ValueError}; /// Inbox capacity. The worker is fast enough that this rarely /// matters; `64` is a generous head-room for bursts. @@ -103,12 +105,61 @@ pub enum DbError { Sqlite { message: String, kind: SqliteErrorKind }, #[error("operation not supported: {0}")] Unsupported(String), + #[error("invalid value: {0}")] + InvalidValue(String), #[error("database worker is no longer available")] WorkerGone, #[error("io error: {0}")] Io(String), } +/// Result of a query / show-data call (schema-less display rows). +/// `None` cells render as NULL; `Some(s)` renders as the string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataResult { + pub table_name: String, + pub columns: Vec, + pub rows: Vec>>, +} + +/// Outcome of a successful INSERT — a count plus the new row(s) +/// fetched immediately after so the user can see what landed +/// (auto-filled IDs, generated shortids, etc.). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InsertResult { + pub rows_affected: usize, + pub data: DataResult, +} + +/// Outcome of a successful UPDATE — a count plus the rows that +/// matched (and were updated). Captured by rowid so that even an +/// UPDATE which changes the WHERE-target column still finds the +/// post-update rows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateResult { + pub rows_affected: usize, + pub data: DataResult, +} + +/// Outcome of a successful DELETE — the directly-deleted-row +/// count plus any cascade effects observed in child tables. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteResult { + pub rows_affected: usize, + pub cascade: Vec, +} + +/// One observed change in a child table caused by referential +/// action on a parent-side DELETE. Detected by row-count diffing +/// the child table immediately before and after the delete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CascadeEffect { + pub relationship_name: String, + pub child_table: String, + pub rows_changed: i64, + pub action: ReferentialAction, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SqliteErrorKind { /// `UNIQUE` constraint, including duplicate primary key. @@ -201,6 +252,27 @@ enum Request { selector: RelationshipSelector, reply: oneshot::Sender, DbError>>, }, + Insert { + table: String, + columns: Option>, + values: Vec, + reply: oneshot::Sender>, + }, + Update { + table: String, + assignments: Vec<(String, Value)>, + filter: RowFilter, + reply: oneshot::Sender>, + }, + Delete { + table: String, + filter: RowFilter, + reply: oneshot::Sender>, + }, + QueryData { + table: String, + reply: oneshot::Sender>, + }, } impl Database { @@ -316,6 +388,61 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + pub async fn insert( + &self, + table: String, + columns: Option>, + values: Vec, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::Insert { + table, + columns, + values, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + + pub async fn update( + &self, + table: String, + assignments: Vec<(String, Value)>, + filter: RowFilter, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::Update { + table, + assignments, + filter, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + + pub async fn delete( + &self, + table: String, + filter: RowFilter, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::Delete { + table, + filter, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + + pub async fn query_data(&self, table: String) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::QueryData { table, 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) } @@ -415,6 +542,32 @@ fn handle_request(conn: &Connection, req: Request) { Request::DropRelationship { selector, reply } => { let _ = reply.send(do_drop_relationship(conn, &selector)); } + Request::Insert { + table, + columns, + values, + reply, + } => { + let _ = reply.send(do_insert(conn, &table, columns.as_deref(), &values)); + } + Request::Update { + table, + assignments, + filter, + reply, + } => { + let _ = reply.send(do_update(conn, &table, &assignments, &filter)); + } + Request::Delete { + table, + filter, + reply, + } => { + let _ = reply.send(do_delete(conn, &table, &filter)); + } + Request::QueryData { table, reply } => { + let _ = reply.send(do_query_data(conn, &table)); + } } } @@ -1028,7 +1181,11 @@ fn do_add_relationship( Ok(()) })?; - do_describe_table(conn, child_table) + // Return the parent (1-side) description so the user sees + // the new relationship via the inbound section — that's the + // perspective that matches the `from .col to ...` + // direction of the command. + do_describe_table(conn, parent_table) } fn do_drop_relationship( @@ -1065,7 +1222,7 @@ fn do_drop_relationship( ) .ok(), }; - let (rel_name, _parent_table, _parent_column, child_table, child_column) = + 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, @@ -1085,7 +1242,9 @@ fn do_drop_relationship( Ok(()) })?; - Ok(Some(do_describe_table(conn, &child_table)?)) + // Show the parent (1-side) afterwards — same direction as + // the command's `from to ...` reading. + Ok(Some(do_describe_table(conn, &parent_table)?)) } fn do_describe_table(conn: &Connection, name: &str) -> Result { @@ -1142,6 +1301,476 @@ fn do_describe_table(conn: &Connection, name: &str) -> Result Result { + let col = schema + .columns + .iter() + .find(|c| c.name == column) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {column}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + let ty = col.user_type.ok_or_else(|| { + DbError::Unsupported(format!( + "column `{column}` has no user-type metadata; cannot validate value" + )) + })?; + value + .bind_for_column(column, ty) + .map_err(|e: ValueError| DbError::InvalidValue(e.to_string())) +} + +fn bound_to_sqlite_value(b: &Bound) -> rusqlite::types::Value { + use rusqlite::types::Value as V; + match b { + Bound::Integer(i) => V::Integer(*i), + Bound::Real(r) => V::Real(*r), + Bound::Text(s) => V::Text(s.clone()), + Bound::Null => V::Null, + } +} + +fn enrich_fk_message(conn: &Connection, table: &str, base: String) -> String { + // SQLite tells us "FOREIGN KEY constraint failed" without + // saying which constraint. We list both: + // - outbound FKs (this table is child) — relevant for + // INSERT and UPDATE that try to set a non-matching FK + // value; + // - inbound FKs (this table is parent) — relevant for + // DELETE / UPDATE on the parent side that violate a + // RESTRICT or NO ACTION constraint. + // The user reads both lists and recognises the relevant + // direction from the operation they ran. Full H1 would + // pinpoint the offending row. + let outbound = read_relationships_outbound(conn, table).unwrap_or_default(); + let inbound = read_relationships_inbound(conn, table).unwrap_or_default(); + if outbound.is_empty() && inbound.is_empty() { + return base; + } + let mut msg = base; + if !outbound.is_empty() { + msg.push_str(". Foreign keys on this table (relevant for INSERT/UPDATE):"); + for r in outbound { + msg.push_str(&format!( + "\n - {}.{} → {}.{}", + table, r.local_column, r.other_table, r.other_column + )); + } + } + if !inbound.is_empty() { + msg.push_str( + "\nThis table is referenced by (relevant for DELETE/UPDATE on parent side):", + ); + for r in inbound { + msg.push_str(&format!( + "\n - {}.{} → {}.{} (on delete {})", + r.other_table, r.other_column, table, r.local_column, r.on_delete + )); + } + } + msg.push_str( + "\nCheck that referenced values exist (for INSERT/UPDATE) or that no children \ + reference these rows (for DELETE/UPDATE on the parent side).", + ); + msg +} + +fn execute_with_fk_enrichment( + conn: &Connection, + table: &str, + sql: &str, + params: &[rusqlite::types::Value], +) -> Result { + let result = conn.execute(sql, rusqlite::params_from_iter(params.iter())); + match result { + Ok(n) => Ok(n), + Err(e) => { + let mut db_err = DbError::from_rusqlite(e); + if let DbError::Sqlite { message, kind } = &db_err { + let lower = message.to_ascii_lowercase(); + if lower.contains("foreign key") { + db_err = DbError::Sqlite { + message: enrich_fk_message(conn, table, message.clone()), + kind: *kind, + }; + } + } + Err(db_err) + } + } +} + +/// Fetch a small `DataResult` containing only the rows whose +/// rowids appear in `rowids`. Used so writes can show only the +/// rows they touched rather than the whole table. +fn query_rows_by_rowid( + conn: &Connection, + table: &str, + rowids: &[i64], +) -> Result { + let schema = read_schema(conn, table)?; + let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); + let column_types: Vec> = + schema.columns.iter().map(|c| c.user_type).collect(); + + if rowids.is_empty() { + return Ok(DataResult { + table_name: table.to_string(), + columns: column_names, + rows: Vec::new(), + }); + } + + let cols_csv = column_names + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let placeholders = (1..=rowids.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(", "); + let sql = format!( + "SELECT {cols} FROM {ident} WHERE rowid IN ({placeholders});", + cols = cols_csv, + ident = quote_ident(table), + ); + let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; + let params: Vec = rowids + .iter() + .map(|id| rusqlite::types::Value::Integer(*id)) + .collect(); + let rows_iter = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |row| { + let mut cells: Vec = + Vec::with_capacity(column_names.len()); + for i in 0..column_names.len() { + cells.push(row.get(i)?); + } + Ok(cells) + }) + .map_err(DbError::from_rusqlite)?; + let mut rows: Vec>> = Vec::new(); + for r in rows_iter { + let cells = r.map_err(DbError::from_rusqlite)?; + let formatted: Vec> = cells + .into_iter() + .zip(column_types.iter()) + .map(|(v, ty)| format_cell(v, *ty)) + .collect(); + rows.push(formatted); + } + Ok(DataResult { + table_name: table.to_string(), + columns: column_names, + rows, + }) +} + +fn count_rows(conn: &Connection, table: &str) -> Result { + let sql = format!("SELECT COUNT(*) FROM {ident};", ident = quote_ident(table)); + conn.query_row(&sql, [], |row| row.get::<_, i64>(0)) + .map_err(DbError::from_rusqlite) +} + +fn do_insert( + conn: &Connection, + table: &str, + user_columns: Option<&[String]>, + user_values: &[Value], +) -> Result { + let schema = read_schema(conn, table)?; + + // Resolve which columns the user is providing values for. + let user_cols: Vec = match user_columns { + Some(cols) => cols.to_vec(), + None => { + // Short form: every non-auto-generated column in + // schema declaration order. Serial and shortid both + // get auto-filled below. + schema + .columns + .iter() + .filter(|c| !matches!(c.user_type, Some(Type::Serial) | Some(Type::ShortId))) + .map(|c| c.name.clone()) + .collect() + } + }; + + if user_cols.len() != user_values.len() { + return Err(DbError::InvalidValue(format!( + "expected {} value(s), got {}", + user_cols.len(), + user_values.len() + ))); + } + + let mut bindings: Vec<(String, Bound)> = Vec::with_capacity(user_cols.len()); + for (col_name, value) in user_cols.iter().zip(user_values.iter()) { + let bound = impl_value_for(&schema, col_name, value)?; + bindings.push((col_name.clone(), bound)); + } + + // Auto-fill any shortid columns the user didn't list. + let provided: std::collections::HashSet = + bindings.iter().map(|(c, _)| c.clone()).collect(); + for c in &schema.columns { + if c.user_type == Some(Type::ShortId) && !provided.contains(&c.name) { + bindings.push((c.name.clone(), Bound::Text(shortid::generate()))); + } + } + + if bindings.is_empty() { + return Err(DbError::InvalidValue( + "INSERT requires at least one column value".to_string(), + )); + } + + let cols_csv = bindings + .iter() + .map(|(c, _)| quote_ident(c)) + .collect::>() + .join(", "); + let placeholders = (1..=bindings.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(", "); + let sql = format!( + "INSERT INTO {ident} ({cols_csv}) VALUES ({placeholders});", + ident = quote_ident(table), + ); + debug!(sql = %sql, "insert"); + let params: Vec = + bindings.iter().map(|(_, b)| bound_to_sqlite_value(b)).collect(); + let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?; + let new_rowid = conn.last_insert_rowid(); + let data = query_rows_by_rowid(conn, table, &[new_rowid])?; + Ok(InsertResult { + rows_affected, + data, + }) +} + +fn do_update( + conn: &Connection, + table: &str, + assignments: &[(String, Value)], + filter: &RowFilter, +) -> Result { + if assignments.is_empty() { + return Err(DbError::InvalidValue( + "UPDATE requires at least one assignment".to_string(), + )); + } + let schema = read_schema(conn, table)?; + + // Capture rowids of matching rows up front so we can fetch + // the updated rows even if the UPDATE changed the WHERE column. + let rowids = match filter { + RowFilter::AllRows => select_all_rowids(conn, table)?, + RowFilter::Where { column, value } => { + let bound = impl_value_for(&schema, column, value)?; + let mut stmt = conn + .prepare(&format!( + "SELECT rowid FROM {ident} WHERE {col} = ?1;", + ident = quote_ident(table), + col = quote_ident(column), + )) + .map_err(DbError::from_rusqlite)?; + let bound_param = bound_to_sqlite_value(&bound); + let rows = stmt + .query_map([&bound_param], |row| row.get::<_, i64>(0)) + .map_err(DbError::from_rusqlite)?; + let mut ids = Vec::new(); + for r in rows { + ids.push(r.map_err(DbError::from_rusqlite)?); + } + ids + } + }; + + let mut params: Vec = Vec::new(); + let mut set_clauses: Vec = Vec::with_capacity(assignments.len()); + for (col, value) in assignments { + let bound = impl_value_for(&schema, col, value)?; + set_clauses.push(format!( + "{col_id} = ?{n}", + col_id = quote_ident(col), + n = params.len() + 1 + )); + params.push(bound_to_sqlite_value(&bound)); + } + + let where_sql = match filter { + RowFilter::AllRows => String::new(), + RowFilter::Where { column, value } => { + let bound = impl_value_for(&schema, column, value)?; + params.push(bound_to_sqlite_value(&bound)); + format!( + " WHERE {col} = ?{n}", + col = quote_ident(column), + n = params.len() + ) + } + }; + + let sql = format!( + "UPDATE {ident} SET {sets}{where_sql};", + ident = quote_ident(table), + sets = set_clauses.join(", "), + ); + debug!(sql = %sql, "update"); + let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?; + let data = query_rows_by_rowid(conn, table, &rowids)?; + Ok(UpdateResult { + rows_affected, + data, + }) +} + +fn select_all_rowids(conn: &Connection, table: &str) -> Result, DbError> { + let mut stmt = conn + .prepare(&format!( + "SELECT rowid FROM {ident};", + ident = quote_ident(table) + )) + .map_err(DbError::from_rusqlite)?; + let rows = stmt + .query_map([], |row| row.get::<_, i64>(0)) + .map_err(DbError::from_rusqlite)?; + let mut ids = Vec::new(); + for r in rows { + ids.push(r.map_err(DbError::from_rusqlite)?); + } + Ok(ids) +} + +fn do_delete( + conn: &Connection, + table: &str, + filter: &RowFilter, +) -> Result { + let schema = read_schema(conn, table)?; + + // Snapshot child-table row counts before the delete so we + // can detect cascade effects via diffing afterwards. ON + // UPDATE CASCADE does not change row counts and so is not + // detected here — it would need value-level diffing, which + // a future iteration can add. + let inbound = read_relationships_inbound(conn, table)?; + let mut before_counts: Vec<(String, i64)> = Vec::with_capacity(inbound.len()); + for r in &inbound { + before_counts.push((r.other_table.clone(), count_rows(conn, &r.other_table)?)); + } + + let mut params: Vec = Vec::new(); + let where_sql = match filter { + RowFilter::AllRows => String::new(), + RowFilter::Where { column, value } => { + let bound = impl_value_for(&schema, column, value)?; + params.push(bound_to_sqlite_value(&bound)); + format!( + " WHERE {col} = ?{n}", + col = quote_ident(column), + n = params.len() + ) + } + }; + let sql = format!( + "DELETE FROM {ident}{where_sql};", + ident = quote_ident(table), + ); + debug!(sql = %sql, "delete"); + let rows_affected = execute_with_fk_enrichment(conn, table, &sql, ¶ms)?; + + // Compare child-table counts after the delete; non-zero + // diffs are cascade effects. + let mut cascade: Vec = Vec::new(); + for (rel, (_child_table, before_count)) in inbound.iter().zip(before_counts.iter()) { + let after_count = count_rows(conn, &rel.other_table)?; + let diff = before_count - after_count; + if diff > 0 { + cascade.push(CascadeEffect { + relationship_name: rel.name.clone(), + child_table: rel.other_table.clone(), + rows_changed: diff, + action: rel.on_delete, + }); + } + } + + Ok(DeleteResult { + rows_affected, + cascade, + }) +} + +fn do_query_data(conn: &Connection, table: &str) -> Result { + let schema = read_schema(conn, table)?; + let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); + let column_types: Vec> = + schema.columns.iter().map(|c| c.user_type).collect(); + + let cols_csv = column_names + .iter() + .map(|c| quote_ident(c)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT {cols} FROM {ident};", + cols = cols_csv, + ident = quote_ident(table), + ); + debug!(sql = %sql, "query_data"); + let mut stmt = conn.prepare(&sql).map_err(DbError::from_rusqlite)?; + let rows_iter = stmt + .query_map([], |row| { + let mut cells: Vec = Vec::with_capacity(column_names.len()); + for i in 0..column_names.len() { + let v: rusqlite::types::Value = row.get(i)?; + cells.push(v); + } + Ok(cells) + }) + .map_err(DbError::from_rusqlite)?; + let mut rows: Vec>> = Vec::new(); + for r in rows_iter { + let cells = r.map_err(DbError::from_rusqlite)?; + let formatted: Vec> = cells + .into_iter() + .zip(column_types.iter()) + .map(|(v, ty)| format_cell(v, *ty)) + .collect(); + rows.push(formatted); + } + Ok(DataResult { + table_name: table.to_string(), + columns: column_names, + rows, + }) +} + +fn format_cell(value: rusqlite::types::Value, ty: Option) -> Option { + use rusqlite::types::Value as V; + match value { + V::Null => None, + V::Integer(i) => Some(if matches!(ty, Some(Type::Bool)) { + (if i == 0 { "false" } else { "true" }).to_string() + } else { + i.to_string() + }), + V::Real(r) => Some(format!("{r}")), + V::Text(s) => Some(s), + V::Blob(b) => Some(format!("", b.len())), + } +} + fn read_relationships_outbound( conn: &Connection, table: &str, @@ -1919,6 +2548,337 @@ mod tests { assert_eq!(name_col.user_type, Some(Type::Text)); } + // --- Data operations (C5) --- + + async fn customers_table(db: &Database) { + db.create_table( + "Customers".to_string(), + vec![col("id", Type::Serial), col("Name", Type::Text)], + vec!["id".to_string()], + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn insert_short_form_skips_serial_column() { + let db = db(); + customers_table(&db).await; + let result = db + .insert( + "Customers".to_string(), + None, + vec![Value::Text("Alice".to_string())], + ) + .await + .unwrap(); + assert_eq!(result.rows_affected, 1); + // The InsertResult itself carries the just-inserted row. + assert_eq!(result.data.rows.len(), 1); + assert_eq!(result.data.rows[0][1], Some("Alice".to_string())); + let data = db.query_data("Customers".to_string()).await.unwrap(); + assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]); + assert_eq!(data.rows.len(), 1); + assert_eq!(data.rows[0][1], Some("Alice".to_string())); + // id was auto-filled by SQLite. + assert_eq!(data.rows[0][0], Some("1".to_string())); + } + + #[tokio::test] + async fn insert_short_form_auto_generates_shortid() { + let db = db(); + db.create_table( + "Tags".to_string(), + vec![col("id", Type::ShortId), col("Label", Type::Text)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.insert( + "Tags".to_string(), + None, + vec![Value::Text("database".to_string())], + ) + .await + .unwrap(); + let data = db.query_data("Tags".to_string()).await.unwrap(); + let id = data.rows[0][0].as_ref().expect("auto-generated id"); + assert!( + id.len() >= 10 && id.len() <= 12, + "expected a base58 shortid, got {id}" + ); + } + + #[tokio::test] + async fn insert_explicit_columns_uses_user_values() { + let db = db(); + customers_table(&db).await; + db.insert( + "Customers".to_string(), + Some(vec!["id".to_string(), "Name".to_string()]), + vec![Value::Number("99".to_string()), Value::Text("Bob".to_string())], + ) + .await + .unwrap(); + let data = db.query_data("Customers".to_string()).await.unwrap(); + assert_eq!(data.rows[0][0], Some("99".to_string())); + assert_eq!(data.rows[0][1], Some("Bob".to_string())); + } + + #[tokio::test] + async fn insert_with_type_mismatch_returns_invalid_value() { + let db = db(); + customers_table(&db).await; + // 42 is a number, but Name expects text. + let err = db + .insert( + "Customers".to_string(), + Some(vec!["Name".to_string()]), + vec![Value::Number("42".to_string())], + ) + .await + .unwrap_err(); + assert!(matches!(err, DbError::InvalidValue(_)), "got {err:?}"); + } + + #[tokio::test] + async fn update_with_where_affects_only_matching_rows() { + let db = db(); + customers_table(&db).await; + for name in ["Alice", "Bob"] { + db.insert( + "Customers".to_string(), + None, + vec![Value::Text(name.to_string())], + ) + .await + .unwrap(); + } + let result = db + .update( + "Customers".to_string(), + vec![("Name".to_string(), Value::Text("Alicia".to_string()))], + RowFilter::Where { + column: "id".to_string(), + value: Value::Number("1".to_string()), + }, + ) + .await + .unwrap(); + assert_eq!(result.rows_affected, 1); + // The UpdateResult contains only the updated rows. + assert_eq!(result.data.rows.len(), 1); + assert_eq!(result.data.rows[0][1], Some("Alicia".to_string())); + let data = db.query_data("Customers".to_string()).await.unwrap(); + assert_eq!(data.rows[0][1], Some("Alicia".to_string())); + assert_eq!(data.rows[1][1], Some("Bob".to_string())); + } + + #[tokio::test] + async fn update_with_all_rows_affects_everything() { + let db = db(); + customers_table(&db).await; + for name in ["Alice", "Bob", "Carol"] { + db.insert( + "Customers".to_string(), + None, + vec![Value::Text(name.to_string())], + ) + .await + .unwrap(); + } + let result = db + .update( + "Customers".to_string(), + vec![("Name".to_string(), Value::Text("X".to_string()))], + RowFilter::AllRows, + ) + .await + .unwrap(); + assert_eq!(result.rows_affected, 3); + assert_eq!(result.data.rows.len(), 3); + } + + #[tokio::test] + async fn delete_with_where_removes_matching_rows() { + let db = db(); + customers_table(&db).await; + for name in ["Alice", "Bob"] { + db.insert( + "Customers".to_string(), + None, + vec![Value::Text(name.to_string())], + ) + .await + .unwrap(); + } + let result = db + .delete( + "Customers".to_string(), + RowFilter::Where { + column: "id".to_string(), + value: Value::Number("1".to_string()), + }, + ) + .await + .unwrap(); + assert_eq!(result.rows_affected, 1); + assert!(result.cascade.is_empty(), "no children to cascade to"); + let data = db.query_data("Customers".to_string()).await.unwrap(); + assert_eq!(data.rows.len(), 1); + assert_eq!(data.rows[0][1], Some("Bob".to_string())); + } + + #[tokio::test] + async fn fk_violation_message_lists_outbound_relationships() { + let db = db(); + // Two-table setup with FK. + customers_table(&db).await; + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial), col("CustId", Type::Int)], + 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, + false, + ) + .await + .unwrap(); + + // Try to insert an Order pointing at a nonexistent Customer. + let err = db + .insert( + "Orders".to_string(), + Some(vec!["CustId".to_string()]), + vec![Value::Number("999".to_string())], + ) + .await + .unwrap_err(); + match err { + DbError::Sqlite { message, .. } => { + assert!( + message.to_ascii_lowercase().contains("foreign key"), + "{message}" + ); + assert!( + message.contains("Orders.CustId → Customers.id"), + "FK enrichment should list the FK: {message}" + ); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[tokio::test] + async fn cascade_delete_propagates_to_children() { + let db = db(); + customers_table(&db).await; + db.create_table( + "Orders".to_string(), + vec![col("id", Type::Serial), col("CustId", Type::Int)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.add_relationship( + None, + "Customers".to_string(), + "id".to_string(), + "Orders".to_string(), + "CustId".to_string(), + ReferentialAction::Cascade, + ReferentialAction::NoAction, + false, + ) + .await + .unwrap(); + db.insert( + "Customers".to_string(), + None, + vec![Value::Text("Alice".to_string())], + ) + .await + .unwrap(); + db.insert( + "Orders".to_string(), + Some(vec!["CustId".to_string()]), + vec![Value::Number("1".to_string())], + ) + .await + .unwrap(); + // Delete Alice — cascades to Orders. + db.delete( + "Customers".to_string(), + RowFilter::Where { + column: "id".to_string(), + value: Value::Number("1".to_string()), + }, + ) + .await + .unwrap(); + let orders = db.query_data("Orders".to_string()).await.unwrap(); + assert!(orders.rows.is_empty(), "child rows should be cascaded"); + } + + #[tokio::test] + async fn query_data_renders_bools_as_words() { + let db = db(); + db.create_table( + "Flags".to_string(), + vec![col("id", Type::Serial), col("Active", Type::Bool)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.insert( + "Flags".to_string(), + None, + vec![Value::Bool(true)], + ) + .await + .unwrap(); + db.insert( + "Flags".to_string(), + None, + vec![Value::Bool(false)], + ) + .await + .unwrap(); + let data = db.query_data("Flags".to_string()).await.unwrap(); + assert_eq!(data.rows[0][1], Some("true".to_string())); + assert_eq!(data.rows[1][1], Some("false".to_string())); + } + + #[tokio::test] + async fn query_data_renders_null_as_none() { + let db = db(); + db.create_table( + "T".to_string(), + vec![col("id", Type::Serial), col("note", Type::Text)], + vec!["id".to_string()], + ) + .await + .unwrap(); + db.insert( + "T".to_string(), + None, + vec![Value::Null], + ) + .await + .unwrap(); + let data = db.query_data("T".to_string()).await.unwrap(); + assert_eq!(data.rows[0][1], None); + } + #[tokio::test] async fn quoted_table_names_round_trip() { let db = db(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index b0d1f52..aed0a9d 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -13,6 +13,7 @@ use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; +use crate::dsl::value::Value; /// A column at table-creation time: a name and a user-facing /// type. Constraints beyond `PRIMARY KEY` (NOT NULL, UNIQUE, @@ -70,6 +71,38 @@ pub enum Command { ShowTable { name: String, }, + /// Insert a single row. `columns` is `None` for the natural- + /// order short form (`insert into T values (...)`); the + /// executor fills in the column list by walking the schema. + Insert { + table: String, + columns: Option>, + values: Vec, + }, + /// Update rows matching the WHERE clause (or all rows when + /// `all_rows` is set, per ADR-0009 opt-in convention). + Update { + table: String, + assignments: Vec<(String, Value)>, + filter: RowFilter, + }, + Delete { + table: String, + filter: RowFilter, + }, + /// Render the rows of a table as a data view in the output. + ShowData { + name: String, + }, +} + +/// How an UPDATE / DELETE selects which rows to operate on. +/// `Where` is the default safe form. `AllRows` is the explicit +/// `--all-rows` flag opt-in for unfiltered operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RowFilter { + Where { column: String, value: Value }, + AllRows, } /// How a `drop relationship` command identifies the relationship @@ -114,6 +147,10 @@ impl Command { Self::AddRelationship { .. } => "add relationship", Self::DropRelationship { .. } => "drop relationship", Self::ShowTable { .. } => "show table", + Self::Insert { .. } => "insert into", + Self::Update { .. } => "update", + Self::Delete { .. } => "delete from", + Self::ShowData { .. } => "show data", } } @@ -126,16 +163,53 @@ impl Command { match self { Self::CreateTable { name, .. } | Self::DropTable { name } - | Self::ShowTable { name } => name, - Self::AddColumn { table, .. } => table, - Self::AddRelationship { child_table, .. } => child_table, + | Self::ShowTable { name } + | Self::ShowData { name } => name, + Self::AddColumn { table, .. } + | Self::Insert { table, .. } + | Self::Update { table, .. } + | Self::Delete { table, .. } => table, + // For relationships we focus on the parent (1-side): + // the structure rendering after add/drop shows that + // table's "Referenced by" entry, which is what the + // user looks at to confirm the relationship. + Self::AddRelationship { parent_table, .. } => parent_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::Endpoints { parent_table, .. } => parent_table, + // For a named drop we don't know the parent table + // until the executor resolves it; the name itself + // is a sensible fallback for logging. RelationshipSelector::Named { name } => name, }, } } + + /// Human-readable subject for the `[ok] ` + /// summary line. Most commands target a single table, but + /// relationship commands are better described by their + /// endpoints than by either side alone. + #[must_use] + pub fn display_subject(&self) -> String { + match self { + Self::AddRelationship { + parent_table, + parent_column, + child_table, + child_column, + .. + } => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"), + Self::DropRelationship { selector } => match selector { + RelationshipSelector::Named { name } => name.clone(), + RelationshipSelector::Endpoints { + parent_table, + parent_column, + child_table, + child_column, + } => format!( + "from {parent_table}.{parent_column} to {child_table}.{child_column}" + ), + }, + _ => self.target_table().to_string(), + } + } } diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index eb8871e..bbef2b7 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -12,9 +12,12 @@ pub mod action; pub mod command; pub mod parser; +pub mod shortid; pub mod types; +pub mod value; pub use action::ReferentialAction; -pub use command::{ColumnSpec, Command, RelationshipSelector}; +pub use command::{ColumnSpec, Command, RelationshipSelector, RowFilter}; pub use parser::{ParseError, parse_command}; pub use types::Type; +pub use value::Value; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 2ce85bb..8f13482 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -14,8 +14,9 @@ use chumsky::error::RichReason; use chumsky::prelude::*; use crate::dsl::action::ReferentialAction; -use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector}; +use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector, RowFilter}; use crate::dsl::types::Type; +use crate::dsl::value::Value; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum ParseError { @@ -128,10 +129,14 @@ fn command_parser<'a>() .ignore_then(identifier()) .map(|name| Command::DropTable { name }); + // `to table` is optional — both `add column to table T: c (text)` + // and `add column T: c (text)` parse identically. + let to_table_optional = keyword_ci("to") + .ignore_then(keyword_ci("table")) + .or_not(); let add_column = keyword_ci("add") .ignore_then(keyword_ci("column")) - .ignore_then(keyword_ci("to")) - .ignore_then(keyword_ci("table")) + .ignore_then(to_table_optional) .ignore_then(identifier()) .then_ignore(just(':').padded()) .then(identifier()) @@ -143,23 +148,211 @@ fn command_parser<'a>() let add_relationship = add_relationship_parser(); let drop_relationship = drop_relationship_parser(); + let show_data = keyword_ci("show") + .ignore_then(keyword_ci("data")) + .ignore_then(identifier()) + .map(|name| Command::ShowData { name }); + let show_table = keyword_ci("show") .ignore_then(keyword_ci("table")) .ignore_then(identifier()) .map(|name| Command::ShowTable { name }); + let insert_cmd = insert_parser(); + let update_cmd = update_parser(); + let delete_cmd = delete_parser(); + choice(( create_table, drop_table, add_column, add_relationship, drop_relationship, + // Order: `show data` before `show table` because both + // start with `show` and the longer keyword is checked + // first via this ordering. + show_data, show_table, + insert_cmd, + update_cmd, + delete_cmd, )) .padded() .then_ignore(end()) } +/// INSERT, accepting three shapes: +/// `insert into T (cols) values (vals)` — explicit columns +/// `insert into T values (vals)` — implicit column order +/// `insert into T (vals)` — short form, omits `values` +/// +/// The short form is disambiguated from the column-list form by +/// trying both alternatives in order; chumsky's `choice` +/// backtracks, and only the all-literals form parses without +/// `values`. +fn insert_parser<'a>() +-> impl Parser<'a, &'a str, Command, extra::Err>> + Clone { + let column_list = just('(') + .padded() + .ignore_then( + identifier() + .separated_by(just(',').padded()) + .at_least(1) + .collect::>(), + ) + .then_ignore(just(')').padded()); + + let value_list = just('(') + .padded() + .ignore_then( + value_literal() + .separated_by(just(',').padded()) + .at_least(1) + .collect::>(), + ) + .then_ignore(just(')').padded()); + + let with_columns_and_values = column_list + .clone() + .then_ignore(keyword_ci("values")) + .then(value_list.clone()) + .map(|(cols, vals)| (Some(cols), vals)); + + let with_values_keyword_only = keyword_ci("values") + .ignore_then(value_list.clone()) + .map(|vals| (None, vals)); + + let bare_value_list = value_list.map(|vals| (None, vals)); + + keyword_ci("insert") + .ignore_then(keyword_ci("into")) + .ignore_then(identifier()) + .then(choice(( + with_columns_and_values, + with_values_keyword_only, + bare_value_list, + ))) + .map(|(table, (columns, values))| Command::Insert { + table, + columns, + values, + }) +} + +/// `update set =[, =...] (where = | --all-rows)`. +fn update_parser<'a>() +-> impl Parser<'a, &'a str, Command, extra::Err>> + Clone { + let assignment = identifier() + .then_ignore(just('=').padded()) + .then(value_literal()); + + let assignments = assignment + .separated_by(just(',').padded()) + .at_least(1) + .collect::>(); + + keyword_ci("update") + .ignore_then(identifier()) + .then_ignore(keyword_ci("set")) + .then(assignments) + .then(filter_clause()) + .map(|((table, assignments), filter)| Command::Update { + table, + assignments, + filter, + }) +} + +/// `delete from (where = | --all-rows)`. +fn delete_parser<'a>() +-> impl Parser<'a, &'a str, Command, extra::Err>> + Clone { + keyword_ci("delete") + .ignore_then(keyword_ci("from")) + .ignore_then(identifier()) + .then(filter_clause()) + .map(|(table, filter)| Command::Delete { table, filter }) +} + +/// Parse the row-filter portion of UPDATE/DELETE: either +/// `where =` or the `--all-rows` flag, with the two +/// being mutually exclusive (specifying both is a parse error). +fn filter_clause<'a>() +-> impl Parser<'a, &'a str, RowFilter, extra::Err>> + Clone { + let where_clause = keyword_ci("where") + .ignore_then(identifier()) + .then_ignore(just('=').padded()) + .then(value_literal()) + .map(|(column, value)| RowFilter::Where { column, value }); + + let all_rows = just("--all-rows").padded().to(RowFilter::AllRows); + + where_clause.or(all_rows).labelled("where clause or --all-rows") +} + +/// Parse a value literal: number, single-quoted string, `null`, +/// `true`, or `false`. +fn value_literal<'a>() +-> impl Parser<'a, &'a str, Value, extra::Err>> + Clone { + choice(( + keyword_ci("null").to(Value::Null), + keyword_ci("true").to(Value::Bool(true)), + keyword_ci("false").to(Value::Bool(false)), + number_literal(), + string_literal(), + )) + .padded() +} + +fn number_literal<'a>() +-> impl Parser<'a, &'a str, Value, extra::Err>> + Clone { + let sign = just('-').or_not(); + let digits = any() + .filter(|c: &char| c.is_ascii_digit()) + .repeated() + .at_least(1) + .collect::(); + let fraction = just('.') + .ignore_then( + any() + .filter(|c: &char| c.is_ascii_digit()) + .repeated() + .at_least(1) + .collect::(), + ) + .or_not(); + sign.then(digits) + .then(fraction) + .map(|((s, whole), frac)| { + let mut out = String::new(); + if s.is_some() { + out.push('-'); + } + out.push_str(&whole); + if let Some(f) = frac { + out.push('.'); + out.push_str(&f); + } + Value::Number(out) + }) +} + +fn string_literal<'a>() +-> impl Parser<'a, &'a str, Value, extra::Err>> + Clone { + // Single-quoted SQL string. `''` inside the literal escapes + // a literal single quote. + let body = just('\'') + .ignore_then( + choice(( + just("''").to('\''), + any().filter(|c: &char| *c != '\''), + )) + .repeated() + .collect::(), + ) + .then_ignore(just('\'')); + body.map(Value::Text) +} + /// `add 1:n relationship [] from

.

to ./// [on delete ] [on update ] [--create-fk]`. fn add_relationship_parser<'a>() @@ -805,6 +998,188 @@ mod tests { assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } + #[test] + fn insert_with_explicit_column_list() { + assert_eq!( + ok("insert into Customers (Name, Email) values ('Alice', 'a@b.com')"), + Command::Insert { + table: "Customers".to_string(), + columns: Some(vec!["Name".to_string(), "Email".to_string()]), + values: vec![ + Value::Text("Alice".to_string()), + Value::Text("a@b.com".to_string()), + ], + } + ); + } + + #[test] + fn insert_short_form_omitting_values_keyword() { + // User typed `insert into T (vals)` without `values`. + // Equivalent to `insert into T values (vals)`. + assert_eq!( + ok("insert into Customers ('Alice')"), + Command::Insert { + table: "Customers".to_string(), + columns: None, + values: vec![Value::Text("Alice".to_string())], + } + ); + } + + #[test] + fn insert_short_form_without_column_list() { + assert_eq!( + ok("insert into Customers values ('Alice', 'a@b.com')"), + Command::Insert { + table: "Customers".to_string(), + columns: None, + values: vec![ + Value::Text("Alice".to_string()), + Value::Text("a@b.com".to_string()), + ], + } + ); + } + + #[test] + fn insert_accepts_mixed_value_kinds() { + assert_eq!( + ok("insert into T values (1, 3.14, 'hi', true, null)"), + Command::Insert { + table: "T".to_string(), + columns: None, + values: vec![ + Value::Number("1".to_string()), + Value::Number("3.14".to_string()), + Value::Text("hi".to_string()), + Value::Bool(true), + Value::Null, + ], + } + ); + } + + #[test] + fn insert_supports_negative_numbers() { + assert_eq!( + ok("insert into T values (-5, -3.14)"), + Command::Insert { + table: "T".to_string(), + columns: None, + values: vec![ + Value::Number("-5".to_string()), + Value::Number("-3.14".to_string()), + ], + } + ); + } + + #[test] + fn string_literal_supports_escaped_single_quote() { + // SQL convention: '' inside a quoted string is a literal '. + assert_eq!( + ok("insert into T values ('don''t panic')"), + Command::Insert { + table: "T".to_string(), + columns: None, + values: vec![Value::Text("don't panic".to_string())], + } + ); + } + + #[test] + fn update_with_where() { + assert_eq!( + ok("update Customers set Name='Alice' where id=1"), + Command::Update { + table: "Customers".to_string(), + assignments: vec![("Name".to_string(), Value::Text("Alice".to_string()))], + filter: RowFilter::Where { + column: "id".to_string(), + value: Value::Number("1".to_string()), + }, + } + ); + } + + #[test] + fn update_with_multiple_assignments() { + assert_eq!( + ok("update Customers set Name='Alice', Email='a@b.com' where id=1"), + Command::Update { + table: "Customers".to_string(), + assignments: vec![ + ("Name".to_string(), Value::Text("Alice".to_string())), + ("Email".to_string(), Value::Text("a@b.com".to_string())), + ], + filter: RowFilter::Where { + column: "id".to_string(), + value: Value::Number("1".to_string()), + }, + } + ); + } + + #[test] + fn update_with_all_rows_flag() { + assert_eq!( + ok("update Customers set Active=false --all-rows"), + Command::Update { + table: "Customers".to_string(), + assignments: vec![("Active".to_string(), Value::Bool(false))], + filter: RowFilter::AllRows, + } + ); + } + + #[test] + fn update_without_where_or_flag_errors() { + let e = err("update Customers set Active=false"); + assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); + } + + #[test] + fn delete_with_where() { + assert_eq!( + ok("delete from Customers where id=1"), + Command::Delete { + table: "Customers".to_string(), + filter: RowFilter::Where { + column: "id".to_string(), + value: Value::Number("1".to_string()), + }, + } + ); + } + + #[test] + fn delete_with_all_rows_flag() { + assert_eq!( + ok("delete from Customers --all-rows"), + Command::Delete { + table: "Customers".to_string(), + filter: RowFilter::AllRows, + } + ); + } + + #[test] + fn delete_without_where_or_flag_errors() { + let e = err("delete from Customers"); + assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); + } + + #[test] + fn show_data_command() { + assert_eq!( + ok("show data Customers"), + Command::ShowData { + name: "Customers".to_string() + } + ); + } + #[test] fn drop_relationship_by_name() { assert_eq!( diff --git a/src/dsl/shortid.rs b/src/dsl/shortid.rs new file mode 100644 index 0000000..3638e92 --- /dev/null +++ b/src/dsl/shortid.rs @@ -0,0 +1,115 @@ +//! Shortid generation and validation. +//! +//! Per ADR-0005, the `shortid` user-facing type is a base58 +//! random identifier of 10–12 characters with no ambiguous +//! glyphs (no `0`/`O`/`I`/`l`). The generator is small enough +//! to live in the DSL crate alongside the type definition. + +use rand::RngExt; + +/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded +/// because they are easily confused in print. +const ALPHABET: &[u8; 58] = + b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +const DEFAULT_LEN: usize = 10; + +/// Length bounds accepted on user-supplied shortid values. +pub const MIN_LEN: usize = 10; +pub const MAX_LEN: usize = 12; + +/// Generate a fresh shortid using thread-local RNG. +#[must_use] +pub fn generate() -> String { + generate_len(DEFAULT_LEN) +} + +#[must_use] +fn generate_len(len: usize) -> String { + let mut rng = rand::rng(); + let mut out = String::with_capacity(len); + for _ in 0..len { + let idx = rng.random_range(0..ALPHABET.len()); + out.push(ALPHABET[idx] as char); + } + out +} + +/// Validate a user-supplied shortid value. +pub fn validate(value: &str) -> Result<(), String> { + if value.len() < MIN_LEN || value.len() > MAX_LEN { + return Err(format!( + "shortid must be {MIN_LEN}–{MAX_LEN} characters; got {} character(s)", + value.chars().count() + )); + } + for c in value.chars() { + if !ALPHABET.contains(&(c as u8)) { + return Err(format!( + "shortid contains '{c}', which is not in the base58 alphabet \ + (no 0, O, I, or l; ASCII letters and digits otherwise)" + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn generated_ids_have_default_length() { + let id = generate(); + assert_eq!(id.len(), DEFAULT_LEN); + } + + #[test] + fn generated_ids_use_only_base58_alphabet() { + for _ in 0..100 { + let id = generate(); + for c in id.chars() { + assert!( + ALPHABET.contains(&(c as u8)), + "char {c:?} not in base58 alphabet" + ); + } + } + } + + #[test] + fn generated_ids_are_not_all_identical() { + // Probabilistically extremely unlikely with a good RNG; + // catches a wholly broken generator (constant output). + let a = generate(); + let b = generate(); + let c = generate(); + assert!( + a != b || b != c, + "all three generated ids were identical: {a}, {b}, {c}" + ); + } + + #[test] + fn validate_accepts_well_formed_values() { + assert!(validate("23456789Ab").is_ok()); // 10 chars + assert!(validate("23456789AbCD").is_ok()); // 12 chars + } + + #[test] + fn validate_rejects_too_short_or_too_long() { + let err = validate("short").unwrap_err(); + assert!(err.contains("characters")); + let err = validate("waytoolongafornow").unwrap_err(); + assert!(err.contains("characters")); + } + + #[test] + fn validate_rejects_ambiguous_glyphs() { + for bad in ["0aaaaaaaaa", "Oaaaaaaaaa", "Iaaaaaaaaa", "laaaaaaaaa"] { + let err = validate(bad).unwrap_err(); + assert!(err.contains("base58"), "for {bad}: {err}"); + } + } +} diff --git a/src/dsl/value.rs b/src/dsl/value.rs new file mode 100644 index 0000000..a7ccabe --- /dev/null +++ b/src/dsl/value.rs @@ -0,0 +1,390 @@ +//! User-facing value literals for INSERT / UPDATE / DELETE. +//! +//! The parser produces a small `Value` enum carrying just the +//! shape of the literal as written. Per-column-type validation +//! happens at execute time, where the schema is known and +//! errors can name the offending column. + +use std::fmt; + +use crate::dsl::shortid; +use crate::dsl::types::Type; + +/// A literal value as parsed from DSL input. +/// +/// `Number` carries the original string so a literal like +/// `3.14` can be stored as a decimal (TEXT) or a real (f64) +/// depending on the destination column. The conversion happens +/// in `bind_for_column`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + Number(String), + Text(String), + Bool(bool), + Null, +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Number(n) => f.write_str(n), + Self::Text(s) => write!(f, "'{}'", s.replace('\'', "''")), + Self::Bool(b) => f.write_str(if *b { "true" } else { "false" }), + Self::Null => f.write_str("null"), + } + } +} + +/// Validated value ready to be bound as a parameter to a SQLite +/// statement. Mirrors the storage choices made in ADR-0005. +#[derive(Debug, Clone, PartialEq)] +pub enum Bound { + Integer(i64), + Real(f64), + Text(String), + Null, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ValueError { + #[error("column `{column}` expects {expected_human}, got {got}")] + TypeMismatch { + column: String, + expected_human: String, + got: String, + }, + #[error("column `{column}`: {message}")] + Format { column: String, message: String }, +} + +impl Value { + /// Validate `self` against `column`'s user-facing type and + /// produce a value ready for binding. + pub fn bind_for_column(&self, column: &str, ty: Type) -> Result { + if matches!(self, Self::Null) { + return Ok(Bound::Null); + } + match ty { + Type::Text | Type::ShortId => self.bind_text(column, ty), + Type::Int | Type::Serial => self.bind_int(column, ty), + Type::Real => self.bind_real(column), + Type::Decimal => self.bind_decimal(column), + Type::Bool => self.bind_bool(column), + Type::Date => self.bind_date(column), + Type::DateTime => self.bind_datetime(column), + Type::Blob => Err(ValueError::Format { + column: column.to_string(), + message: "literal `blob` values are not supported in DSL yet".to_string(), + }), + } + } + + fn bind_text(&self, column: &str, ty: Type) -> Result { + match self { + Self::Text(s) => { + if ty == Type::ShortId { + shortid::validate(s).map_err(|message| ValueError::Format { + column: column.to_string(), + message, + })?; + } + Ok(Bound::Text(s.clone())) + } + other => Err(ValueError::TypeMismatch { + column: column.to_string(), + expected_human: format!("a quoted string for `{ty}`"), + got: other.kind_name().to_string(), + }), + } + } + + fn bind_int(&self, column: &str, ty: Type) -> Result { + match self { + Self::Number(n) => n + .parse::() + .map(Bound::Integer) + .map_err(|_| ValueError::Format { + column: column.to_string(), + message: format!("`{n}` is not a valid {ty} (whole number expected)"), + }), + other => Err(ValueError::TypeMismatch { + column: column.to_string(), + expected_human: format!("a whole number for `{ty}`"), + got: other.kind_name().to_string(), + }), + } + } + + fn bind_real(&self, column: &str) -> Result { + match self { + Self::Number(n) => n + .parse::() + .map(Bound::Real) + .map_err(|_| ValueError::Format { + column: column.to_string(), + message: format!("`{n}` is not a valid real number"), + }), + other => Err(ValueError::TypeMismatch { + column: column.to_string(), + expected_human: "a real number".to_string(), + got: other.kind_name().to_string(), + }), + } + } + + fn bind_decimal(&self, column: &str) -> Result { + match self { + Self::Number(n) => { + // Validate parse-ability so a typo like `3..14` is rejected; + // we still store the original string to preserve precision. + if n.parse::().is_err() { + return Err(ValueError::Format { + column: column.to_string(), + message: format!("`{n}` is not a valid decimal number"), + }); + } + Ok(Bound::Text(n.clone())) + } + other => Err(ValueError::TypeMismatch { + column: column.to_string(), + expected_human: "a decimal number".to_string(), + got: other.kind_name().to_string(), + }), + } + } + + fn bind_bool(&self, column: &str) -> Result { + match self { + Self::Bool(b) => Ok(Bound::Integer(i64::from(*b))), + other => Err(ValueError::TypeMismatch { + column: column.to_string(), + expected_human: "`true` or `false`".to_string(), + got: other.kind_name().to_string(), + }), + } + } + + fn bind_date(&self, column: &str) -> Result { + match self { + Self::Text(s) => { + validate_date(s).map_err(|message| ValueError::Format { + column: column.to_string(), + message, + })?; + Ok(Bound::Text(s.clone())) + } + other => Err(ValueError::TypeMismatch { + column: column.to_string(), + expected_human: "a quoted date 'YYYY-MM-DD'".to_string(), + got: other.kind_name().to_string(), + }), + } + } + + fn bind_datetime(&self, column: &str) -> Result { + match self { + Self::Text(s) => { + validate_datetime(s).map_err(|message| ValueError::Format { + column: column.to_string(), + message, + })?; + Ok(Bound::Text(s.clone())) + } + other => Err(ValueError::TypeMismatch { + column: column.to_string(), + expected_human: "a quoted datetime 'YYYY-MM-DDTHH:MM:SS'".to_string(), + got: other.kind_name().to_string(), + }), + } + } + + const fn kind_name(&self) -> &'static str { + match self { + Self::Number(_) => "number", + Self::Text(_) => "string", + Self::Bool(_) => "boolean", + Self::Null => "null", + } + } +} + +fn validate_date(s: &str) -> Result<(), String> { + // Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions. + let bytes = s.as_bytes(); + if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' { + return Err(format!( + "`{s}` is not a date in `YYYY-MM-DD` form" + )); + } + let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?; + let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?; + let day = parse_digits(&s[8..10]).ok_or_else(|| format!("`{s}`: invalid day"))?; + if !(1..=9999).contains(&year) { + return Err(format!("`{s}`: year {year} out of range 1..=9999")); + } + if !(1..=12).contains(&month) { + return Err(format!("`{s}`: month {month} out of range 1..=12")); + } + if !(1..=31).contains(&day) { + return Err(format!("`{s}`: day {day} out of range 1..=31")); + } + Ok(()) +} + +fn validate_datetime(s: &str) -> Result<(), String> { + // Minimum: YYYY-MM-DDTHH:MM:SS = 19 chars. Allow optional + // fractional seconds (.fff) and optional Z or ±HH:MM offset. + if s.len() < 19 { + return Err(format!( + "`{s}` is too short for a datetime in `YYYY-MM-DDTHH:MM:SS` form" + )); + } + let date_part = &s[0..10]; + validate_date(date_part)?; + let bytes = s.as_bytes(); + if bytes[10] != b'T' { + return Err(format!("`{s}`: missing `T` separator between date and time")); + } + if bytes[13] != b':' || bytes[16] != b':' { + return Err(format!("`{s}`: time portion must be `HH:MM:SS`")); + } + let hour = parse_digits(&s[11..13]).ok_or_else(|| format!("`{s}`: invalid hour"))?; + let min = parse_digits(&s[14..16]).ok_or_else(|| format!("`{s}`: invalid minute"))?; + let sec = parse_digits(&s[17..19]).ok_or_else(|| format!("`{s}`: invalid second"))?; + if hour > 23 { + return Err(format!("`{s}`: hour {hour} out of range 0..=23")); + } + if min > 59 { + return Err(format!("`{s}`: minute {min} out of range 0..=59")); + } + if sec > 60 { + return Err(format!( + "`{s}`: second {sec} out of range 0..=60 (60 allowed for leap second)" + )); + } + // Anything after position 19 is optional fractional / timezone + // suffix; we don't strictly validate it here (a future iteration + // can tighten this if needed). + Ok(()) +} + +fn parse_digits(s: &str) -> Option { + if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) { + return None; + } + s.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn n(s: &str) -> Value { + Value::Number(s.to_string()) + } + fn t(s: &str) -> Value { + Value::Text(s.to_string()) + } + + #[test] + fn null_binds_to_null_for_any_type() { + for ty in Type::all() { + // Skip blob — null still works there too. + assert_eq!(Value::Null.bind_for_column("c", *ty).unwrap(), Bound::Null); + } + } + + #[test] + fn integer_for_int_column() { + assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42)); + assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7)); + } + + #[test] + fn non_integer_for_int_column_is_format_error() { + let err = n("3.14").bind_for_column("c", Type::Int).unwrap_err(); + match err { + ValueError::Format { message, .. } => assert!(message.contains("whole number")), + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn string_for_int_column_is_type_mismatch() { + let err = t("hello").bind_for_column("c", Type::Int).unwrap_err(); + assert!(matches!(err, ValueError::TypeMismatch { .. })); + } + + #[test] + fn text_for_text_column() { + assert_eq!( + t("hi").bind_for_column("c", Type::Text).unwrap(), + Bound::Text("hi".to_string()) + ); + } + + #[test] + fn shortid_validation_runs_on_text_for_shortid_column() { + let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err(); + assert!(matches!(err, ValueError::Format { .. })); + + // Well-formed shortid binds fine. + assert_eq!( + t("23456789Ab").bind_for_column("c", Type::ShortId).unwrap(), + Bound::Text("23456789Ab".to_string()) + ); + } + + #[test] + fn bool_for_bool_column_maps_to_zero_or_one() { + assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1)); + assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0)); + } + + #[test] + fn date_iso_only() { + assert_eq!( + t("2025-01-15").bind_for_column("c", Type::Date).unwrap(), + Bound::Text("2025-01-15".to_string()) + ); + let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err(); + assert!(matches!(err, ValueError::Format { .. })); + } + + #[test] + fn date_range_check() { + let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err(); + assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month"))); + } + + #[test] + fn datetime_iso_only() { + assert_eq!( + t("2025-01-15T14:30:00") + .bind_for_column("c", Type::DateTime) + .unwrap(), + Bound::Text("2025-01-15T14:30:00".to_string()) + ); + let err = t("2025-01-15 14:30:00") + .bind_for_column("c", Type::DateTime) + .unwrap_err(); + assert!(matches!(err, ValueError::Format { .. })); + } + + #[test] + fn decimal_validates_numeric_string() { + assert_eq!( + n("3.14").bind_for_column("c", Type::Decimal).unwrap(), + Bound::Text("3.14".to_string()) + ); + let err = n("3..14").bind_for_column("c", Type::Decimal).unwrap_err(); + assert!(matches!(err, ValueError::Format { .. })); + } + + #[test] + fn blob_inserts_are_explicitly_unsupported_for_now() { + let err = t("0xdead").bind_for_column("c", Type::Blob).unwrap_err(); + assert!(matches!(err, ValueError::Format { message, .. } if message.contains("blob"))); + } +} diff --git a/src/event.rs b/src/event.rs index 5966f20..ad15fda 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,7 +7,7 @@ use crossterm::event::KeyEvent; -use crate::db::TableDescription; +use crate::db::{DataResult, DeleteResult, InsertResult, TableDescription, UpdateResult}; use crate::dsl::Command; #[derive(Debug, Clone)] @@ -25,6 +25,20 @@ pub enum AppEvent { command: Command, description: Option, }, + /// A `show data` query succeeded. + DslDataSucceeded { command: Command, data: DataResult }, + DslInsertSucceeded { + command: Command, + result: InsertResult, + }, + DslUpdateSucceeded { + command: Command, + result: UpdateResult, + }, + DslDeleteSucceeded { + command: Command, + result: DeleteResult, + }, /// A DSL command failed. `error` is already a friendly /// message produced via `DbError::friendly_message`. DslFailed { diff --git a/src/runtime.rs b/src/runtime.rs index dddf63f..a89ef5f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -26,7 +26,9 @@ use tracing::{debug, error, info, warn}; use crate::action::Action; use crate::app::App; -use crate::db::{Database, DbError, TableDescription}; +use crate::db::{ + DataResult, Database, DbError, DeleteResult, InsertResult, TableDescription, UpdateResult, +}; use crate::dsl::Command; use crate::event::AppEvent; use crate::theme::Theme; @@ -121,10 +123,26 @@ fn spawn_dsl_dispatch( tokio::spawn(async move { let outcome = execute_command(&database, command.clone()).await; let event = match outcome { - Ok(description) => AppEvent::DslSucceeded { + Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded { command: command.clone(), description, }, + Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded { + command: command.clone(), + data, + }, + Ok(CommandOutcome::Insert(result)) => AppEvent::DslInsertSucceeded { + command: command.clone(), + result, + }, + Ok(CommandOutcome::Update(result)) => AppEvent::DslUpdateSucceeded { + command: command.clone(), + result, + }, + Ok(CommandOutcome::Delete(result)) => AppEvent::DslDeleteSucceeded { + command: command.clone(), + result, + }, Err(error) => AppEvent::DslFailed { command: command.clone(), error, @@ -141,15 +159,23 @@ fn spawn_dsl_dispatch( Ok(tables) => { let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await; } - Err(e) => warn!(error = %e, "post-DDL list_tables failed"), + Err(e) => warn!(error = %e, "post-list_tables failed"), } }); } +enum CommandOutcome { + Schema(Option), + Query(DataResult), + Insert(InsertResult), + Update(UpdateResult), + Delete(DeleteResult), +} + async fn execute_command( database: &Database, command: Command, -) -> Result, String> { +) -> Result { match command { Command::CreateTable { name, @@ -158,17 +184,17 @@ async fn execute_command( } => database .create_table(name, columns, primary_key) .await - .map(Some) + .map(|d| CommandOutcome::Schema(Some(d))) .map_err(friendly), Command::DropTable { name } => database .drop_table(name) .await - .map(|()| None) + .map(|()| CommandOutcome::Schema(None)) .map_err(friendly), Command::AddColumn { table, column, ty } => database .add_column(table, column, ty) .await - .map(Some) + .map(|d| CommandOutcome::Schema(Some(d))) .map_err(friendly), Command::AddRelationship { name, @@ -191,16 +217,45 @@ async fn execute_command( create_fk, ) .await - .map(Some) + .map(|d| CommandOutcome::Schema(Some(d))) .map_err(friendly), Command::DropRelationship { selector } => database .drop_relationship(selector) .await + .map(CommandOutcome::Schema) .map_err(friendly), Command::ShowTable { name } => database .describe_table(name) .await - .map(Some) + .map(|d| CommandOutcome::Schema(Some(d))) + .map_err(friendly), + Command::Insert { + table, + columns, + values, + } => database + .insert(table, columns, values) + .await + .map(CommandOutcome::Insert) + .map_err(friendly), + Command::Update { + table, + assignments, + filter, + } => database + .update(table, assignments, filter) + .await + .map(CommandOutcome::Update) + .map_err(friendly), + Command::Delete { table, filter } => database + .delete(table, filter) + .await + .map(CommandOutcome::Delete) + .map_err(friendly), + Command::ShowData { name } => database + .query_data(name) + .await + .map(CommandOutcome::Query) .map_err(friendly), } } diff --git a/src/ui.rs b/src/ui.rs index d11df10..bd747ed 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -129,31 +129,73 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area vertical: 1, }); - // 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). + // Render every output line into a wrapped Paragraph and let + // ratatui handle the wrapping; we then use the wrapped row + // count to cap scroll correctly. Bottom-anchoring (most + // recent visible by default) is achieved by computing the + // scroll offset relative to the bottom of the wrapped view. 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); + + // Compute the total wrapped row count first, working from + // OutputLines directly (so the borrow ends before the + // mutable `note_output_viewport` call below). + let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width); + app.note_output_viewport(visible, total_wrapped); + let lines: Vec> = app .output .iter() - .skip(start) - .take(end - start) .map(|line| render_output_line(line, theme)) .collect(); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + + let max_scroll = total_wrapped.saturating_sub(visible); + let effective_scroll = app.output_scroll.min(max_scroll); + // Paragraph::scroll((y, _)) sets the topmost visible row. + // We want bottom-anchored: y = max_scroll - effective_scroll + // so scroll==0 shows the bottom and PageUp moves y down to + // reveal older content. + let scroll_y = max_scroll.saturating_sub(effective_scroll); + let scroll_y_u16 = u16::try_from(scroll_y).unwrap_or(u16::MAX); frame.render_widget(block, area); - let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); - frame.render_widget(paragraph, inner); + frame.render_widget(paragraph.scroll((scroll_y_u16, 0)), inner); +} + +/// Approximate the number of display rows the output buffer +/// will occupy after width-wrapping. Computed directly from +/// `OutputLine`s (rather than the rendered `Line` objects) so +/// the calculation can run before we hand `&mut App` to the +/// scroll-cap update. Ratatui's exact `line_count` is gated +/// behind an unstable feature; this character-based +/// approximation is close enough for scroll capping — off-by-one +/// at boundaries is acceptable. +fn approximate_wrapped_rows_from_output( + output: &std::collections::VecDeque, + width: u16, +) -> usize { + if width == 0 { + return output.len(); + } + let w = usize::from(width); + output + .iter() + .map(|line| { + // Tag width matches `render_output_line` exactly so + // the row count is right when the panel is too narrow + // for the natural line. + let tag_len = match line.kind { + OutputKind::Echo => match line.mode_at_submission { + Mode::Simple => "[simple] ".len(), + Mode::Advanced => "[advanced] ".len(), + }, + OutputKind::System => "[system] ".len(), + OutputKind::Error => "[error] ".len(), + }; + let total = tag_len + line.text.chars().count(); + if total == 0 { 1 } else { total.div_ceil(w) } + }) + .sum() } fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index b6576fd..0ebc300 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -12,8 +12,10 @@ use ratatui::backend::TestBackend; use rdbms_playground::action::Action; use rdbms_playground::app::{App, OutputKind}; -use rdbms_playground::db::{ColumnDescription, RelationshipEnd, TableDescription}; -use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type}; +use rdbms_playground::db::{ + ColumnDescription, DataResult, InsertResult, RelationshipEnd, TableDescription, +}; +use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value}; use rdbms_playground::event::AppEvent; use rdbms_playground::mode::Mode; use rdbms_playground::theme::Theme; @@ -357,7 +359,7 @@ fn drop_table_flow_clears_items_list() { } #[test] -fn add_relationship_flow_shows_outbound_section() { +fn add_relationship_flow_shows_parent_side_with_inbound_section() { let mut app = App::new(); type_str( &mut app, @@ -378,35 +380,28 @@ fn add_relationship_flow_shows_outbound_section() { })] ); - // 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 { + // The runtime now feeds back the parent (Customers) so the + // user sees the new relationship via the "Referenced by" + // section — same direction as the command's `from ` + // reading. + 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: "Customers".to_string(), - other_column: "Id".to_string(), - local_column: "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, }], - inbound_relationships: Vec::new(), }; app.update(AppEvent::DslSucceeded { command: Command::AddRelationship { @@ -419,13 +414,19 @@ fn add_relationship_flow_shows_outbound_section() { on_update: ReferentialAction::NoAction, create_fk: false, }, - description: Some(orders), + description: Some(customers), }); 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("Referenced by:"), "{rendered}"); + assert!(rendered.contains("Orders.CustId"), "{rendered}"); assert!(rendered.contains("on delete cascade"), "{rendered}"); + // The [ok] subject lists the endpoints. Long lines wrap in + // the panel, so we check the first half of the phrase only. + assert!( + rendered.contains("from Customers.Id"), + "{rendered}" + ); } #[test] @@ -463,6 +464,85 @@ fn add_relationship_flow_shows_inbound_section_on_parent() { assert!(rendered.contains("Orders.CustId → Id"), "{rendered}"); } +#[test] +fn insert_flow_emits_action_and_renders_data() { + let mut app = App::new(); + + type_str(&mut app, "insert into Customers values ('Alice')"); + let actions = submit(&mut app); + assert_eq!( + actions, + vec![Action::ExecuteDsl(Command::Insert { + table: "Customers".to_string(), + columns: None, + values: vec![Value::Text("Alice".to_string())], + })] + ); + + // Simulate the runtime feeding back an InsertResult. + let data = DataResult { + table_name: "Customers".to_string(), + columns: vec!["id".to_string(), "Name".to_string()], + rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]], + }; + app.update(AppEvent::DslInsertSucceeded { + command: Command::Insert { + table: "Customers".to_string(), + columns: None, + values: vec![Value::Text("Alice".to_string())], + }, + result: InsertResult { + rows_affected: 1, + data, + }, + }); + let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); + assert!( + rendered.contains("1 row(s) inserted"), + "should show row count:\n{rendered}" + ); + assert!( + rendered.contains("Alice"), + "should auto-show new row:\n{rendered}" + ); + assert!( + rendered.contains("id") && rendered.contains("Name"), + "should show column headers:\n{rendered}" + ); +} + +#[test] +fn delete_with_all_rows_emits_correct_action() { + let mut app = App::new(); + type_str(&mut app, "delete from Customers --all-rows"); + let actions = submit(&mut app); + assert_eq!( + actions, + vec![Action::ExecuteDsl(Command::Delete { + table: "Customers".to_string(), + filter: RowFilter::AllRows, + })] + ); +} + +#[test] +fn show_data_for_empty_table_renders_placeholder() { + let mut app = App::new(); + let data = DataResult { + table_name: "Customers".to_string(), + columns: vec!["id".to_string(), "Name".to_string()], + rows: Vec::new(), + }; + app.update(AppEvent::DslDataSucceeded { + command: Command::ShowData { + name: "Customers".to_string(), + }, + data, + }); + let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); + assert!(rendered.contains("(no rows)"), "{rendered}"); +} + #[test] fn dsl_failure_shows_friendly_error_in_output() { let mut app = App::new();