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:
@@ -0,0 +1,154 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user