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:
+207
-28
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user