Files
rdbms-playground/src/dsl/action.rs
T
claude@clouddev1 165068269b 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.
2026-05-07 14:52:51 +00:00

155 lines
4.4 KiB
Rust

//! Referential actions for foreign-key relationships.
//!
//! These map directly onto SQLite's `ON DELETE` / `ON UPDATE`
//! clause vocabulary. `SET DEFAULT` is intentionally omitted
//! until column DEFAULTs (C3 partial) are supported.
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReferentialAction {
/// Default — referenced rows can't be deleted while
/// referencing rows exist (deferred check).
NoAction,
/// Like NoAction but immediate.
Restrict,
/// On parent row delete/update, set the FK column to NULL.
SetNull,
/// On parent row delete/update, propagate to dependent rows.
Cascade,
}
impl ReferentialAction {
/// The user-facing keyword as it appears in DSL input. Note
/// `set null` is two words; the parser handles that as a
/// single phrase.
#[must_use]
pub const fn keyword(self) -> &'static str {
match self {
Self::NoAction => "no action",
Self::Restrict => "restrict",
Self::SetNull => "set null",
Self::Cascade => "cascade",
}
}
/// The corresponding SQL clause as written in DDL.
#[must_use]
pub const fn sql_clause(self) -> &'static str {
match self {
Self::NoAction => "NO ACTION",
Self::Restrict => "RESTRICT",
Self::SetNull => "SET NULL",
Self::Cascade => "CASCADE",
}
}
/// All actions, in stable order.
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::NoAction, Self::Restrict, Self::SetNull, Self::Cascade]
}
/// Default action when none is specified — matches the SQL
/// standard.
#[must_use]
pub const fn default_action() -> Self {
Self::NoAction
}
}
impl Default for ReferentialAction {
fn default() -> Self {
Self::default_action()
}
}
impl fmt::Display for ReferentialAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.keyword())
}
}
/// Error returned when parsing an unknown action keyword.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown referential action '{found}' (expected one of: {expected})")]
pub struct UnknownAction {
pub found: String,
pub expected: String,
}
impl FromStr for ReferentialAction {
type Err = UnknownAction;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Case-insensitive comparison; tolerates internal
// whitespace ("set null") by collapsing it.
let normalised: String = s
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
for &action in Self::all() {
if normalised == action.keyword() {
return Ok(action);
}
}
Err(UnknownAction {
found: s.to_string(),
expected: Self::all()
.iter()
.map(|a| a.keyword())
.collect::<Vec<_>>()
.join(", "),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn keyword_round_trip() {
for &a in ReferentialAction::all() {
assert_eq!(a.keyword().parse::<ReferentialAction>().unwrap(), a);
}
}
#[test]
fn parsing_is_case_insensitive_and_whitespace_tolerant() {
assert_eq!(
"Cascade".parse::<ReferentialAction>().unwrap(),
ReferentialAction::Cascade
);
assert_eq!(
"SET NULL".parse::<ReferentialAction>().unwrap(),
ReferentialAction::SetNull
);
assert_eq!(
"set null".parse::<ReferentialAction>().unwrap(),
ReferentialAction::SetNull
);
assert_eq!(
"No Action".parse::<ReferentialAction>().unwrap(),
ReferentialAction::NoAction
);
}
#[test]
fn unknown_action_lists_alternatives() {
let err = "destroy".parse::<ReferentialAction>().unwrap_err();
assert_eq!(err.found, "destroy");
assert!(err.expected.contains("cascade"));
assert!(err.expected.contains("set null"));
}
#[test]
fn sql_clause_mapping() {
assert_eq!(ReferentialAction::SetNull.sql_clause(), "SET NULL");
assert_eq!(ReferentialAction::NoAction.sql_clause(), "NO ACTION");
}
}