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:
+378
-3
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user