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