Foreign-key relationships, rebuild-table, polish round
DSL:
- add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
[on delete <action>] [on update <action>] [--create-fk]
- drop relationship <name> | from <P>.<col> to <C>.<col>
- show table <name> for re-displaying a structure on demand
Database (ADR-0013):
- Rebuild-table primitive following SQLite's
ALTER-via-rebuild recipe (foreign_keys=OFF outside tx,
copy-by-name, foreign_key_check before commit). Reusable for
B2 (column drops/renames/type changes).
- ReferentialAction enum (no action / restrict / set null /
cascade); SET DEFAULT awaits column DEFAULTs.
- __rdbms_playground_relationships metadata table -- names,
auto-generated as <Parent>_<pcol>_to_<Child>_<ccol>.
- Type::fk_target_type() validation at declaration; friendly
errors for type mismatch, non-PK target, missing column,
duplicate name.
- describe_table populates symmetric outbound + inbound
relationship lists. drop_table refuses while inbound
references exist; outbound metadata cleaned up alongside drop.
App / UI:
- In-line cursor editing in the input field: Left, Right,
Home, End, Delete, Backspace honoring UTF-8 boundaries.
- PageUp / PageDown scrolls the output buffer; viewport row
count fed back from the renderer via App::note_output_viewport
so scroll is capped against the actual visible area
(regression-tested) and snaps to the bottom on new output.
- Failure messages quote the command portion ("verb target"
failed: ...) for visual clarity; RelationshipSelector has a
proper Display impl so "no such relationship" reads cleanly.
- Structure rendering shows References / Referenced by sections.
Docs:
- ADR-0013 covers naming, metadata table, symmetric view, and
the rebuild-table strategy.
- requirements.md updates: C3 (FK done), B2 (primitive in),
T3 (compound-PK FK still pending). New entries: I1a (cursor
editing -- landed), I1b (Ctrl-A/E and readline shortcuts --
pending), V4 partial scroll, V5 (show family), C3a (modify
relationship -- deferred).
Tests: 154 passing (140 lib + 14 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
+128
-19
@@ -12,8 +12,8 @@ use ratatui::backend::TestBackend;
|
||||
|
||||
use rdbms_playground::action::Action;
|
||||
use rdbms_playground::app::{App, OutputKind};
|
||||
use rdbms_playground::db::{ColumnDescription, TableDescription};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, Type};
|
||||
use rdbms_playground::db::{ColumnDescription, RelationshipEnd, TableDescription};
|
||||
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type};
|
||||
use rdbms_playground::event::AppEvent;
|
||||
use rdbms_playground::mode::Mode;
|
||||
use rdbms_playground::theme::Theme;
|
||||
@@ -40,7 +40,7 @@ fn submit(app: &mut App) -> Vec<Action> {
|
||||
app.update(key(KeyCode::Enter))
|
||||
}
|
||||
|
||||
fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String {
|
||||
fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||||
terminal
|
||||
@@ -63,7 +63,7 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() {
|
||||
let theme = Theme::dark();
|
||||
|
||||
type_str(&mut app, "create table Customers with pk");
|
||||
let pre_render = rendered_text(&app, &theme, 80, 24);
|
||||
let pre_render = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(
|
||||
pre_render.contains("create table Customers"),
|
||||
"input field should display the typed text:\n{pre_render}"
|
||||
@@ -82,7 +82,7 @@ fn typing_then_submitting_a_dsl_command_emits_execute_action() {
|
||||
})]
|
||||
);
|
||||
assert!(app.input.is_empty(), "input buffer cleared on submit");
|
||||
let post_render = rendered_text(&app, &theme, 80, 24);
|
||||
let post_render = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(
|
||||
post_render.contains("running:"),
|
||||
"output panel should show the running notice:\n{post_render}"
|
||||
@@ -96,7 +96,7 @@ fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
|
||||
type_str(&mut app, "hello world");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
let rendered = rendered_text(&app, &theme, 80, 24);
|
||||
let rendered = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(
|
||||
rendered.contains("parse error"),
|
||||
"output panel should show the parse error:\n{rendered}"
|
||||
@@ -108,7 +108,7 @@ fn mode_switch_changes_label_and_subsequent_echoes() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
let initial = rendered_text(&app, &theme, 80, 24);
|
||||
let initial = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(initial.contains("SIMPLE"));
|
||||
assert!(!initial.contains("ADVANCED"));
|
||||
|
||||
@@ -116,7 +116,7 @@ fn mode_switch_changes_label_and_subsequent_echoes() {
|
||||
submit(&mut app);
|
||||
assert_eq!(app.mode, Mode::Advanced);
|
||||
|
||||
let after_switch = rendered_text(&app, &theme, 80, 24);
|
||||
let after_switch = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(after_switch.contains("ADVANCED"));
|
||||
|
||||
type_str(&mut app, "select 1");
|
||||
@@ -162,9 +162,9 @@ fn quit_command_returns_quit_action() {
|
||||
#[test]
|
||||
fn rendering_works_at_minimum_useful_size() {
|
||||
// Sanity check that the layout does not panic at small sizes.
|
||||
let app = App::new();
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
let _ = rendered_text(&app, &theme, 40, 12);
|
||||
let _ = rendered_text(&mut app, &theme, 40, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -174,14 +174,14 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
||||
|
||||
// No `:` yet — prompt shows SIMPLE.
|
||||
type_str(&mut app, "sel");
|
||||
let before = rendered_text(&app, &theme, 80, 24);
|
||||
let before = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(before.contains("SIMPLE"));
|
||||
assert!(!before.contains("Advanced:"));
|
||||
|
||||
// Reset and type `:` first — prompt should flip immediately.
|
||||
app.input.clear();
|
||||
type_str(&mut app, ":");
|
||||
let after_colon = rendered_text(&app, &theme, 80, 24);
|
||||
let after_colon = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(
|
||||
after_colon.contains("Advanced:"),
|
||||
"input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
|
||||
@@ -193,7 +193,7 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
||||
while !app.input.is_empty() {
|
||||
app.update(key(KeyCode::Backspace));
|
||||
}
|
||||
let after_revert = rendered_text(&app, &theme, 80, 24);
|
||||
let after_revert = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(after_revert.contains("SIMPLE"));
|
||||
assert!(!after_revert.contains("Advanced:"));
|
||||
}
|
||||
@@ -203,14 +203,14 @@ fn status_bar_lists_quit_and_submit_in_all_modes() {
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
let simple = rendered_text(&app, &theme, 80, 24);
|
||||
let simple = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(simple.contains("Enter"), "status bar lists Enter");
|
||||
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
||||
assert!(simple.contains("mode advanced"));
|
||||
|
||||
type_str(&mut app, "mode advanced");
|
||||
submit(&mut app);
|
||||
let advanced = rendered_text(&app, &theme, 80, 24);
|
||||
let advanced = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(advanced.contains("Enter"));
|
||||
assert!(advanced.contains("Ctrl-C"));
|
||||
assert!(advanced.contains("mode simple"));
|
||||
@@ -239,6 +239,8 @@ fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
|
||||
primary_key: *pk,
|
||||
})
|
||||
.collect(),
|
||||
outbound_relationships: Vec::new(),
|
||||
inbound_relationships: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +273,7 @@ fn create_table_flow_updates_tables_list_and_structure_view() {
|
||||
assert_eq!(app.tables, vec!["Customers".to_string()]);
|
||||
assert_eq!(app.current_table, Some(desc));
|
||||
|
||||
let rendered = rendered_text(&app, &theme, 80, 24);
|
||||
let rendered = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(
|
||||
rendered.contains("Customers"),
|
||||
"items panel should list Customers:\n{rendered}"
|
||||
@@ -320,7 +322,7 @@ fn add_column_flow_updates_structure_view() {
|
||||
description: Some(updated.clone()),
|
||||
});
|
||||
assert_eq!(app.current_table, Some(updated));
|
||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("Name text"));
|
||||
}
|
||||
|
||||
@@ -349,11 +351,118 @@ fn drop_table_flow_clears_items_list() {
|
||||
|
||||
assert!(app.tables.is_empty());
|
||||
assert!(app.current_table.is_none());
|
||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("(none yet)"));
|
||||
assert!(rendered.contains("[ok] drop table Customers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_relationship_flow_shows_outbound_section() {
|
||||
let mut app = App::new();
|
||||
type_str(
|
||||
&mut app,
|
||||
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade",
|
||||
);
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::ExecuteDsl(Command::AddRelationship {
|
||||
name: None,
|
||||
parent_table: "Customers".to_string(),
|
||||
parent_column: "Id".to_string(),
|
||||
child_table: "Orders".to_string(),
|
||||
child_column: "CustId".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
create_fk: false,
|
||||
})]
|
||||
);
|
||||
|
||||
// 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 {
|
||||
name: "Customers_Id_to_Orders_CustId".to_string(),
|
||||
other_table: "Customers".to_string(),
|
||||
other_column: "Id".to_string(),
|
||||
local_column: "CustId".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
inbound_relationships: Vec::new(),
|
||||
};
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::AddRelationship {
|
||||
name: None,
|
||||
parent_table: "Customers".to_string(),
|
||||
parent_column: "Id".to_string(),
|
||||
child_table: "Orders".to_string(),
|
||||
child_column: "CustId".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
create_fk: false,
|
||||
},
|
||||
description: Some(orders),
|
||||
});
|
||||
|
||||
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("on delete cascade"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_relationship_flow_shows_inbound_section_on_parent() {
|
||||
let mut app = App::new();
|
||||
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: "Orders".to_string(),
|
||||
other_column: "CustId".to_string(),
|
||||
local_column: "Id".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
};
|
||||
app.update(AppEvent::DslSucceeded {
|
||||
command: Command::AddColumn {
|
||||
table: "Customers".to_string(),
|
||||
column: "extra".to_string(),
|
||||
ty: Type::Text,
|
||||
},
|
||||
description: Some(customers),
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||
assert!(rendered.contains("Orders.CustId → Id"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_shows_friendly_error_in_output() {
|
||||
let mut app = App::new();
|
||||
@@ -365,7 +474,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
|
||||
},
|
||||
error: "no such table: Ghost".to_string(),
|
||||
});
|
||||
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
rendered.contains("Ghost"),
|
||||
"error should mention the table:\n{rendered}"
|
||||
|
||||
Reference in New Issue
Block a user