165068269b
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.
155 lines
4.4 KiB
Rust
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");
|
|
}
|
|
}
|