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);
}
+964 -4
View File
File diff suppressed because it is too large Load Diff
+81 -7
View File
@@ -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<Vec<String>>,
values: Vec<Value>,
},
/// 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] <verb> <subject>`
/// 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(),
}
}
}
+4 -1
View File
@@ -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;
+378 -3
View File
@@ -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<Rich<'a, char>>> + Clone {
let column_list = just('(')
.padded()
.ignore_then(
identifier()
.separated_by(just(',').padded())
.at_least(1)
.collect::<Vec<_>>(),
)
.then_ignore(just(')').padded());
let value_list = just('(')
.padded()
.ignore_then(
value_literal()
.separated_by(just(',').padded())
.at_least(1)
.collect::<Vec<_>>(),
)
.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 <T> set <col>=<val>[, <col>=<val>...] (where <col>=<val> | --all-rows)`.
fn update_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let assignment = identifier()
.then_ignore(just('=').padded())
.then(value_literal());
let assignments = assignment
.separated_by(just(',').padded())
.at_least(1)
.collect::<Vec<_>>();
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 <T> (where <col>=<val> | --all-rows)`.
fn delete_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + 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 <col>=<val>` 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<Rich<'a, char>>> + 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<Rich<'a, char>>> + 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<Rich<'a, char>>> + Clone {
let sign = just('-').or_not();
let digits = any()
.filter(|c: &char| c.is_ascii_digit())
.repeated()
.at_least(1)
.collect::<String>();
let fraction = just('.')
.ignore_then(
any()
.filter(|c: &char| c.is_ascii_digit())
.repeated()
.at_least(1)
.collect::<String>(),
)
.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<Rich<'a, char>>> + 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::<String>(),
)
.then_ignore(just('\''));
body.map(Value::Text)
}
/// `add 1:n relationship [<name>] from <P>.<col> to <C>.<col>
/// [on delete <action>] [on update <action>] [--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!(
+115
View File
@@ -0,0 +1,115 @@
//! Shortid generation and validation.
//!
//! Per ADR-0005, the `shortid` user-facing type is a base58
//! random identifier of 1012 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}");
}
}
}
+390
View File
@@ -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<Bound, ValueError> {
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<Bound, ValueError> {
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<Bound, ValueError> {
match self {
Self::Number(n) => n
.parse::<i64>()
.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<Bound, ValueError> {
match self {
Self::Number(n) => n
.parse::<f64>()
.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<Bound, ValueError> {
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::<f64>().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<Bound, ValueError> {
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<Bound, ValueError> {
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<Bound, ValueError> {
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<u32> {
if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit()) {
return None;
}
s.parse::<u32>().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")));
}
}
+15 -1
View File
@@ -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<TableDescription>,
},
/// 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 {
+64 -9
View File
@@ -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<TableDescription>),
Query(DataResult),
Insert(InsertResult),
Update(UpdateResult),
Delete(DeleteResult),
}
async fn execute_command(
database: &Database,
command: Command,
) -> Result<Option<TableDescription>, String> {
) -> Result<CommandOutcome, String> {
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),
}
}
+59 -17
View File
@@ -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<Line<'_>> = 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<OutputLine>,
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> {