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 <Parent>` 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 `<Parent>_<pcol>_to_<Child>_<ccol>`
  (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.
This commit is contained in:
claude@clouddev1
2026-05-07 16:33:25 +00:00
parent 165068269b
commit 305e5083d5
16 changed files with 2638 additions and 109 deletions
+207 -28
View File
@@ -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<TableDescription>) {
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<String>) {
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<String>) {
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<String> {
let header = data.columns.clone();
let body: Vec<Vec<String>> = 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<usize> = 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<String> = 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);
}