Files
rdbms-playground/src/dsl/action.rs
T
claude@clouddev1 6ca297579e round-5 follow-up r2: migrate all thiserror Display attributes to catalog
Completes the i18n sweep started in the previous commit. All
remaining hand-rolled user-facing English strings inside
thiserror #[error(...)] attributes have been moved into the
catalog. Drops the thiserror dependency entirely.

Twelve error types migrated:

- dsl::action::UnknownAction         → parse.custom.unknown_action
- dsl::parser::ParseError            → parse.error_wrapper + parse.empty
- dsl::value::ValueError             → value.{type_mismatch,format}
- persistence::csv_io::CsvError      → persistence.csv.*
- persistence::mod::PersistenceError → persistence.{io,encode}
- persistence::yaml::YamlError       → persistence.yaml.*
- persistence::migrations::MigrateError → persistence.migrate.*
- project::lock::LockError           → project.lock.*
- project::naming::NamingError       → project.naming.*
- project::naming::UserNameError     → project.user_name.*
- project::mod::ProjectError         → project.{path_not_found,...}
- project::mod::SafeDeleteError      → project.safe_delete.*
- archive::ArchiveError              → archive.*
- cli::ArgsError                     → cli.*
- db::DbError                        → db.error.*

Pattern per type: drop thiserror::Error derive, write manual
Display calling crate::t!(), keep #[from] semantics via
explicit From impls, override Error::source() where applicable
so #[source]-style chaining is preserved.

Why this matters (user rationale): "fine to have fallbacks for
errors that are purely technical, but lift the output to a
place where it can be localized later and where an adjustment
with friendly text is easily possible if any of them become
part of the happy path." All surface strings now live in
en-US.yaml and can be reworded or localized without touching
Rust source.

Tests: 769 passing, 0 failed, 1 ignored. Clippy clean with
-D warnings. Cargo.toml: drop thiserror = "2.0.18".
2026-05-13 21:24:51 +00:00

170 lines
4.8 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.
///
/// Display flows through the i18n catalog
/// (`parse.custom.unknown_action`) so the wording can be
/// localised or adjusted without touching the type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownAction {
pub found: String,
pub expected: String,
}
impl std::fmt::Display for UnknownAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&crate::t!(
"parse.custom.unknown_action",
found = self.found,
expected = self.expected,
))
}
}
impl std::error::Error for UnknownAction {}
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");
}
}