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:
claude@clouddev1
2026-05-07 14:52:51 +00:00
parent c1e52920eb
commit 165068269b
12 changed files with 2632 additions and 56 deletions
+374 -3
View File
@@ -13,7 +13,8 @@
use chumsky::error::RichReason;
use chumsky::prelude::*;
use crate::dsl::command::{ColumnSpec, Command};
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{ColumnSpec, Command, RelationshipSelector};
use crate::dsl::types::Type;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@@ -139,9 +140,178 @@ fn command_parser<'a>()
.then_ignore(just(')').padded())
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
choice((create_table, drop_table, add_column))
let add_relationship = add_relationship_parser();
let drop_relationship = drop_relationship_parser();
let show_table = keyword_ci("show")
.ignore_then(keyword_ci("table"))
.ignore_then(identifier())
.map(|name| Command::ShowTable { name });
choice((
create_table,
drop_table,
add_column,
add_relationship,
drop_relationship,
show_table,
))
.padded()
.then_ignore(end())
}
/// `add 1:n relationship [<name>] from <P>.<col> to <C>.<col>
/// [on delete <action>] [on update <action>] [--create-fk]`.
fn add_relationship_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let one_to_n = just('1').padded().ignore_then(just(':').padded()).ignore_then(
any()
.filter(|c: &char| *c == 'n' || *c == 'N')
.padded(),
);
let optional_name = keyword_ci("as").ignore_then(identifier()).or_not();
keyword_ci("add")
.ignore_then(one_to_n)
.ignore_then(keyword_ci("relationship"))
.ignore_then(optional_name)
.then_ignore(keyword_ci("from"))
.then(qualified_column())
.then_ignore(keyword_ci("to"))
.then(qualified_column())
.then(referential_clauses())
.then(create_fk_flag())
.map(
|((((name, parent), child), (on_delete, on_update)), create_fk)| {
Command::AddRelationship {
name,
parent_table: parent.0,
parent_column: parent.1,
child_table: child.0,
child_column: child.1,
on_delete,
on_update,
create_fk,
}
},
)
}
/// `drop relationship <name>` or
/// `drop relationship from <P>.<col> to <C>.<col>`.
fn drop_relationship_parser<'a>()
-> impl Parser<'a, &'a str, Command, extra::Err<Rich<'a, char>>> + Clone {
let endpoints_form = keyword_ci("from")
.ignore_then(qualified_column())
.then_ignore(keyword_ci("to"))
.then(qualified_column())
.map(|(parent, child)| RelationshipSelector::Endpoints {
parent_table: parent.0,
parent_column: parent.1,
child_table: child.0,
child_column: child.1,
});
let named_form = identifier().map(|name| RelationshipSelector::Named { name });
keyword_ci("drop")
.ignore_then(keyword_ci("relationship"))
.ignore_then(choice((endpoints_form, named_form)))
.map(|selector| Command::DropRelationship { selector })
}
/// Parse `<Table>.<Column>` returning (table, column).
fn qualified_column<'a>()
-> impl Parser<'a, &'a str, (String, String), extra::Err<Rich<'a, char>>> + Clone {
identifier()
.then_ignore(just('.').padded())
.then(identifier())
}
/// Optional `on delete <action>` and/or `on update <action>`,
/// in either order. Default to `NoAction` when omitted.
fn referential_clauses<'a>() -> impl Parser<
'a,
&'a str,
(ReferentialAction, ReferentialAction),
extra::Err<Rich<'a, char>>,
> + Clone {
let target = keyword_ci("delete")
.to(ReferentialActionTarget::Delete)
.or(keyword_ci("update").to(ReferentialActionTarget::Update));
let clause = keyword_ci("on")
.ignore_then(target)
.then(action_keyword())
.map(|(t, a)| (t, a));
clause
.repeated()
.at_most(2)
.collect::<Vec<_>>()
.try_map(|clauses, span| {
let mut on_delete = None;
let mut on_update = None;
for (target, action) in clauses {
let slot = match target {
ReferentialActionTarget::Delete => &mut on_delete,
ReferentialActionTarget::Update => &mut on_update,
};
if slot.is_some() {
return Err(Rich::custom(
span,
format!("`on {target}` specified twice"),
));
}
*slot = Some(action);
}
Ok((
on_delete.unwrap_or_else(ReferentialAction::default_action),
on_update.unwrap_or_else(ReferentialAction::default_action),
))
})
}
#[derive(Debug, Clone, Copy)]
enum ReferentialActionTarget {
Delete,
Update,
}
impl std::fmt::Display for ReferentialActionTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Delete => "delete",
Self::Update => "update",
})
}
}
/// Parse a referential-action keyword: `cascade`, `restrict`,
/// `set null`, or `no action`. The two-word forms come first in
/// the alternatives so they're tried before the one-word forms;
/// because the first words are unique to each phrase
/// (`set`/`no` for two-word, `cascade`/`restrict` for one-word)
/// there is no ambiguity.
fn action_keyword<'a>()
-> impl Parser<'a, &'a str, ReferentialAction, extra::Err<Rich<'a, char>>> + Clone {
choice((
keyword_ci("set")
.ignore_then(keyword_ci("null"))
.to(ReferentialAction::SetNull),
keyword_ci("no")
.ignore_then(keyword_ci("action"))
.to(ReferentialAction::NoAction),
keyword_ci("cascade").to(ReferentialAction::Cascade),
keyword_ci("restrict").to(ReferentialAction::Restrict),
))
}
fn create_fk_flag<'a>()
-> impl Parser<'a, &'a str, bool, extra::Err<Rich<'a, char>>> + Clone {
just("--create-fk")
.padded()
.then_ignore(end())
.or_not()
.map(|opt| opt.is_some())
}
/// Parse the optional `with pk [<spec>]` clause that may follow
@@ -471,6 +641,207 @@ mod tests {
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
fn rel(
name: Option<&str>,
parent: (&str, &str),
child: (&str, &str),
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
) -> Command {
Command::AddRelationship {
name: name.map(String::from),
parent_table: parent.0.to_string(),
parent_column: parent.1.to_string(),
child_table: child.0.to_string(),
child_column: child.1.to_string(),
on_delete,
on_update,
create_fk,
}
}
#[test]
fn add_relationship_minimal() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_name() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete_set_null() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete set null"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::SetNull,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_both_actions_in_either_order() {
let expected = rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::SetNull,
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e = err(
"add 1:n relationship from C.id to O.cid on delete cascade on delete restrict",
);
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn add_relationship_with_create_fk_flag() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId --create-fk"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_keywords_are_case_insensitive() {
assert_eq!(
ok("ADD 1:N RELATIONSHIP FROM Customers.Id TO Orders.CustId ON DELETE CASCADE"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_unknown_action_errors() {
let e = err("add 1:n relationship from C.id to O.cid on delete obliterate");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn drop_relationship_by_name() {
assert_eq!(
ok("drop relationship cust_orders"),
Command::DropRelationship {
selector: RelationshipSelector::Named {
name: "cust_orders".to_string()
}
}
);
}
#[test]
fn show_table_simple() {
assert_eq!(
ok("show table Customers"),
Command::ShowTable {
name: "Customers".to_string()
}
);
}
#[test]
fn drop_relationship_by_endpoints() {
assert_eq!(
ok("drop relationship from Customers.Id to Orders.CustId"),
Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: "Customers".to_string(),
parent_column: "Id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
}
}
);
}
#[test]
fn identifier_allows_underscores_and_digits_after_start() {
assert_eq!(