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:
+111
-31
@@ -12,8 +12,10 @@ use ratatui::backend::TestBackend;
|
||||
|
||||
use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::{App, OutputKind};
|
||||
use rdbms_playground::db::{ColumnDescription, RelationshipEnd, TableDescription};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type};
|
||||
use rdbms_playground::db::{
|
||||
ColumnDescription, DataResult, InsertResult, RelationshipEnd, TableDescription,
|
||||
};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::mode::Mode;
|
||||
use rdbms_playground::theme::Theme;
|
||||
@@ -357,7 +359,7 @@ fn drop_table_flow_clears_items_list() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_relationship_flow_shows_outbound_section() {
|
||||
fn add_relationship_flow_shows_parent_side_with_inbound_section() {
|
||||
let mut app = App::new();
|
||||
type_str(
|
||||
&mut app,
|
||||
@@ -378,35 +380,28 @@ fn add_relationship_flow_shows_outbound_section() {
|
||||
})]
|
||||
);
|
||||
|
||||
// Simulate the runtime feeding back a description with the
|
||||
// outbound relationship populated.
|
||||
let orders = TableDescription {
|
||||
name: "Orders".to_string(),
|
||||
columns: vec![
|
||||
ColumnDescription {
|
||||
name: "id".to_string(),
|
||||
user_type: Some(Type::Serial),
|
||||
sqlite_type: "INTEGER".to_string(),
|
||||
notnull: false,
|
||||
primary_key: true,
|
||||
},
|
||||
ColumnDescription {
|
||||
name: "CustId".to_string(),
|
||||
user_type: Some(Type::Int),
|
||||
sqlite_type: "INTEGER".to_string(),
|
||||
notnull: false,
|
||||
primary_key: false,
|
||||
},
|
||||
],
|
||||
outbound_relationships: vec![RelationshipEnd {
|
||||
// The runtime now feeds back the parent (Customers) so the
|
||||
// user sees the new relationship via the "Referenced by"
|
||||
// section — same direction as the command's `from <Parent>`
|
||||
// reading.
|
||||
let customers = TableDescription {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![ColumnDescription {
|
||||
name: "Id".to_string(),
|
||||
user_type: Some(Type::Serial),
|
||||
sqlite_type: "INTEGER".to_string(),
|
||||
notnull: false,
|
||||
primary_key: true,
|
||||
}],
|
||||
outbound_relationships: Vec::new(),
|
||||
inbound_relationships: vec![RelationshipEnd {
|
||||
name: "Customers_Id_to_Orders_CustId".to_string(),
|
||||
other_table: "Customers".to_string(),
|
||||
other_column: "Id".to_string(),
|
||||
local_column: "CustId".to_string(),
|
||||
other_table: "Orders".to_string(),
|
||||
other_column: "CustId".to_string(),
|
||||
local_column: "Id".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
inbound_relationships: Vec::new(),
|
||||
};
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::AddRelationship {
|
||||
@@ -419,13 +414,19 @@ fn add_relationship_flow_shows_outbound_section() {
|
||||
on_update: ReferentialAction::NoAction,
|
||||
create_fk: false,
|
||||
},
|
||||
description: Some(orders),
|
||||
description: Some(customers),
|
||||
});
|
||||
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("References:"), "{rendered}");
|
||||
assert!(rendered.contains("CustId → Customers.Id"), "{rendered}");
|
||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||
assert!(rendered.contains("Orders.CustId"), "{rendered}");
|
||||
assert!(rendered.contains("on delete cascade"), "{rendered}");
|
||||
// The [ok] subject lists the endpoints. Long lines wrap in
|
||||
// the panel, so we check the first half of the phrase only.
|
||||
assert!(
|
||||
rendered.contains("from Customers.Id"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -463,6 +464,85 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
|
||||
assert!(rendered.contains("Orders.CustId → Id"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_flow_emits_action_and_renders_data() {
|
||||
let mut app = App::new();
|
||||
|
||||
type_str(&mut app, "insert into Customers values ('Alice')");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: None,
|
||||
values: vec![Value::Text("Alice".to_string())],
|
||||
})]
|
||||
);
|
||||
|
||||
// Simulate the runtime feeding back an InsertResult.
|
||||
let data = DataResult {
|
||||
table_name: "Customers".to_string(),
|
||||
columns: vec!["id".to_string(), "Name".to_string()],
|
||||
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
|
||||
};
|
||||
app.update(AppEvent::DslInsertSucceeded {
|
||||
command: Command::Insert {
|
||||
table: "Customers".to_string(),
|
||||
columns: None,
|
||||
values: vec![Value::Text("Alice".to_string())],
|
||||
},
|
||||
result: InsertResult {
|
||||
rows_affected: 1,
|
||||
data,
|
||||
},
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
rendered.contains("1 row(s) inserted"),
|
||||
"should show row count:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("Alice"),
|
||||
"should auto-show new row:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("id") && rendered.contains("Name"),
|
||||
"should show column headers:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_all_rows_emits_correct_action() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "delete from Customers --all-rows");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::Delete {
|
||||
table: "Customers".to_string(),
|
||||
filter: RowFilter::AllRows,
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_data_for_empty_table_renders_placeholder() {
|
||||
let mut app = App::new();
|
||||
let data = DataResult {
|
||||
table_name: "Customers".to_string(),
|
||||
columns: vec!["id".to_string(), "Name".to_string()],
|
||||
rows: Vec::new(),
|
||||
};
|
||||
app.update(AppEvent::DslDataSucceeded {
|
||||
command: Command::ShowData {
|
||||
name: "Customers".to_string(),
|
||||
},
|
||||
data,
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("(no rows)"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_shows_friendly_error_in_output() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user