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}