Skip to main content

ferro_rs/validation/
constraint_map.rs

1//! Defensive mapping of DB UNIQUE-constraint violations to field-level
2//! [`ValidationError`]s.
3//!
4//! A [`ConstraintMap`] is registered at the call site with the constraint names
5//! and corresponding field/message pairs. When an [`sea_orm::DbErr`] is a UNIQUE
6//! violation that matches a registered entry, [`ConstraintMap::try_map`] returns
7//! `Ok(ValidationError)` carrying the entry's field and message. Any
8//! non-matching `DbErr` — including non-UNIQUE errors and unregistered UNIQUE
9//! violations — is returned as `Err(original_dberr)` unchanged so the caller's
10//! `?` reaches the existing [`From<sea_orm::DbErr> for ActionError`] passthrough.
11//!
12//! All constraint/field/message strings are consumer-owned. The `framework`
13//! crate holds no application-specific literals.
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use ferro_rs::{ConstraintMap, MapConstraintExt};
19//!
20//! let map = ConstraintMap::new()
21//!     .on("pages_slug_unique", "slug", "has already been taken")
22//!     .sqlite("pages.slug");
23//!
24//! // In a handler:
25//! let page = new_page.insert(db).await
26//!     .map_constraint(&map, &data, "/pages/new")?;
27//! ```
28
29use sea_orm::error::SqlErr;
30use sea_orm::{DbErr, RuntimeErr, SqlxError};
31
32use crate::validation::error::ValidationError;
33
34/// A single registered mapping entry.
35#[derive(Clone)]
36struct ConstraintEntry {
37    /// Postgres structured constraint name — primary `.on()` key.
38    pg_name: String,
39    /// SQLite `table.column` discriminator (set via `.sqlite()`).
40    sqlite_key: Option<String>,
41    /// Logical field the error attaches to.
42    field: String,
43    /// User-visible message.
44    message: String,
45}
46
47/// Consumer-registered map from DB UNIQUE-constraint violations to field-level
48/// validation errors. Construct per call site (cheap; no global state).
49///
50/// # First-match semantics
51///
52/// Entries are matched in registration order. The first entry whose Postgres
53/// constraint name or SQLite `table.column` key matches the violation wins.
54/// Register the most specific entries first when multiple UNIQUE constraints
55/// exist on one table.
56///
57/// # Postgres vs SQLite identity
58///
59/// - **Postgres:** matched by constraint name via the `DatabaseError::constraint()`
60///   trait method (protocol field `'n'`). No message-string parsing.
61/// - **SQLite:** matched by `table.column` extracted from the error message
62///   (`"UNIQUE constraint failed: table.column"`). SQLite does not expose
63///   structured constraint names so the message token is the reliable identifier.
64///
65/// A single registration can cover both backends by chaining `.sqlite("t.c")`
66/// after `.on(...)`.
67#[derive(Clone, Default)]
68pub struct ConstraintMap {
69    entries: Vec<ConstraintEntry>,
70}
71
72impl ConstraintMap {
73    /// Create an empty `ConstraintMap`.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Register a UNIQUE constraint name → field/message mapping.
79    ///
80    /// `pg_constraint` is the Postgres constraint name (the structured value
81    /// in the DB error protocol, e.g. `"pages_slug_unique"`). It is also used
82    /// as the logical entry key when chaining `.sqlite(...)`.
83    ///
84    /// Optionally chain `.sqlite("table.column")` immediately after to add a
85    /// SQLite discriminator to the same entry so one registration covers both
86    /// backends.
87    ///
88    /// # Example
89    ///
90    /// ```rust,ignore
91    /// ConstraintMap::new()
92    ///     .on("pages_slug_unique", "slug", "has already been taken")
93    ///     .sqlite("pages.slug");
94    /// ```
95    pub fn on(
96        mut self,
97        pg_constraint: impl Into<String>,
98        field: impl Into<String>,
99        message: impl Into<String>,
100    ) -> Self {
101        self.entries.push(ConstraintEntry {
102            pg_name: pg_constraint.into(),
103            sqlite_key: None,
104            field: field.into(),
105            message: message.into(),
106        });
107        self
108    }
109
110    /// Add a SQLite `table.column` discriminator to the LAST registered entry.
111    ///
112    /// Must be chained immediately after `.on(...)`. No-op when called without
113    /// a prior `.on()` call on this map.
114    ///
115    /// SQLite does not expose structured constraint names; the `table.column`
116    /// token parsed from the error message is the reliable identifier for SQLite
117    /// backends (dev/CI environments).
118    pub fn sqlite(mut self, table_col: impl Into<String>) -> Self {
119        if let Some(last) = self.entries.last_mut() {
120            last.sqlite_key = Some(table_col.into());
121        }
122        self
123    }
124
125    /// Map a DB UNIQUE-constraint violation to a field-level [`ValidationError`].
126    ///
127    /// Returns `Ok(ValidationError)` when `err` is a UNIQUE violation that
128    /// matches a registered entry. Returns `Err(err)` **unchanged** (by move)
129    /// when:
130    /// - `err` is not a UNIQUE violation, or
131    /// - `err` is a UNIQUE violation but no registered entry matches.
132    ///
133    /// The returned `ValidationError` carries the entry's `field` and `message`
134    /// and composes with `.with_old_input(&data).into_action_error(url)` exactly
135    /// like a Phase 190 async-rule failure.
136    ///
137    /// # Safety contract (SC2)
138    ///
139    /// This method NEVER swallows a `DbErr`. A non-matching error is returned
140    /// unchanged so the caller's `?` reaches `From<DbErr> for ActionError`.
141    pub fn try_map(&self, err: DbErr) -> Result<ValidationError, DbErr> {
142        // Step 1: portable violation-type gate (borrows err, does NOT consume it).
143        // All non-UNIQUE DbErr variants fall through immediately unchanged.
144        if !matches!(err.sql_err(), Some(SqlErr::UniqueConstraintViolation(_))) {
145            return Err(err);
146        }
147
148        // Step 2a: Postgres constraint name via the DatabaseError trait method.
149        // `e.constraint()` dispatches to PgDatabaseError::constraint() at runtime
150        // (returns protocol field 'n'). No downcast, no #[cfg] guard needed.
151        let pg_name: Option<String> = match &err {
152            DbErr::Exec(RuntimeErr::SqlxError(SqlxError::Database(e)))
153            | DbErr::Query(RuntimeErr::SqlxError(SqlxError::Database(e))) => {
154                e.constraint().map(ToOwned::to_owned)
155            }
156            _ => None,
157        };
158
159        // Step 2b: SQLite table.column from message string.
160        // sql_err() borrows err again — legal because err has not been moved.
161        // Defensive parse: split on ": " and take token after it; None on any
162        // unexpected format (never panics — T-191-01 mitigation).
163        let sqlite_key: Option<String> = match err.sql_err() {
164            Some(SqlErr::UniqueConstraintViolation(msg)) => {
165                // "UNIQUE constraint failed: table.column" → "table.column"
166                msg.split(": ").nth(1).map(|s| s.trim().to_owned())
167            }
168            _ => None,
169        };
170
171        // Step 3: first-match entry lookup.
172        for entry in &self.entries {
173            let pg_hit = pg_name
174                .as_deref()
175                .map(|c| c == entry.pg_name)
176                .unwrap_or(false);
177            let sqlite_hit = sqlite_key
178                .as_deref()
179                .zip(entry.sqlite_key.as_deref())
180                .map(|(k, r)| k == r)
181                .unwrap_or(false);
182            if pg_hit || sqlite_hit {
183                let mut ve = ValidationError::new();
184                ve.add(&entry.field, &entry.message);
185                return Ok(ve);
186            }
187        }
188
189        // Step 4: no entry matched — fall through unchanged (SC2).
190        Err(err)
191    }
192}
193
194/// Extension trait on `Result<T, DbErr>` for ergonomic call-site mapping.
195///
196/// Eliminates closure ladders at the write site: instead of chaining
197/// `.map_err(|e| map.try_map(e).map(...).unwrap_or_else(...))`, use:
198///
199/// ```rust,ignore
200/// let page = new_page.insert(db).await
201///     .map_constraint(&map, &data, "/pages/new")?;
202/// ```
203///
204/// On a matching UNIQUE violation the `ValidationError` is flashed into the
205/// session and an [`crate::http::action::ActionError`] configured to redirect
206/// to `url` is returned (identical to the Phase 190 surfacing chain).
207///
208/// On any non-matching error, `From<DbErr> for ActionError` is applied —
209/// the existing passthrough — so no error is ever swallowed.
210pub trait MapConstraintExt<T> {
211    /// Map a DB UNIQUE-constraint violation to a field-level error and redirect.
212    ///
213    /// See the trait documentation for full semantics.
214    fn map_constraint(
215        self,
216        map: &ConstraintMap,
217        data: &serde_json::Value,
218        url: impl Into<String>,
219    ) -> Result<T, crate::http::action::ActionError>;
220}
221
222impl<T> MapConstraintExt<T> for Result<T, DbErr> {
223    fn map_constraint(
224        self,
225        map: &ConstraintMap,
226        data: &serde_json::Value,
227        url: impl Into<String>,
228    ) -> Result<T, crate::http::action::ActionError> {
229        self.map_err(|err| match map.try_map(err) {
230            Ok(ve) => ve.with_old_input(data).into_action_error(url),
231            Err(original) => crate::http::action::ActionError::from(original),
232        })
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    // Non-DB unit tests — no DB singleton needed, no #[serial] required.
241    // These cover SC2 (passthrough contract) and builder correctness.
242    // DB-backed SQLite identity and TOCTOU simulation tests live in
243    // framework/tests/constraint_map_integration.rs (Plan 02).
244
245    #[test]
246    fn non_unique_dberr_passes_through_unchanged() {
247        // SC2: a non-UNIQUE DbErr must be returned UNCHANGED.
248        let map = ConstraintMap::new()
249            .on("some_constraint", "field", "message")
250            .sqlite("t.col");
251        let err = DbErr::Custom("boom".to_string());
252        match map.try_map(err) {
253            Err(DbErr::Custom(msg)) => assert_eq!(msg, "boom"),
254            other => panic!("expected Err(DbErr::Custom(\"boom\")), got {other:?}"),
255        }
256    }
257
258    #[test]
259    fn empty_map_passes_through_any_dberr_unchanged() {
260        // An empty ConstraintMap must never swallow any error.
261        let map = ConstraintMap::new();
262        let err = DbErr::Custom("any error".to_string());
263        match map.try_map(err) {
264            Err(DbErr::Custom(msg)) => assert_eq!(msg, "any error"),
265            other => panic!("expected Err(DbErr::Custom), got {other:?}"),
266        }
267    }
268
269    #[test]
270    fn builder_chains_on_and_sqlite_without_panic() {
271        // Verify .on(...).sqlite(...) builds without panic and the map is reusable.
272        let map = ConstraintMap::new()
273            .on("a_constraint", "field_a", "msg a")
274            .sqlite("table_a.col_a")
275            .on("b_constraint", "field_b", "msg b");
276
277        // Two entries registered; passing a non-UNIQUE error exercises the code
278        // path without needing a DB connection.
279        let err = DbErr::Custom("test".to_string());
280        assert!(map.try_map(err).is_err(), "non-UNIQUE must fall through");
281
282        // Clone the map to verify Clone is derived correctly.
283        let map2 = map.clone();
284        let err2 = DbErr::Custom("test2".to_string());
285        assert!(map2.try_map(err2).is_err());
286    }
287
288    #[test]
289    fn sqlite_no_op_without_prior_on() {
290        // .sqlite() without a prior .on() must be a no-op (not panic).
291        let map = ConstraintMap::new().sqlite("table.col");
292        assert!(map.entries.is_empty());
293    }
294}