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