Skip to main content

es_entity/
error.rs

1//! Types for working with errors produced by es-entity.
2
3use thiserror::Error;
4
5/// Error type for entity hydration failures (reconstructing entities from events).
6#[derive(Error, Debug)]
7pub enum EntityHydrationError {
8    #[error("EntityHydrationError - UninitializedFieldError: {0}")]
9    UninitializedFieldError(#[from] derive_builder::UninitializedFieldError),
10    #[error("EntityHydrationError - Deserialization: {0}")]
11    EventDeserialization(#[from] serde_json::Error),
12    /// A snapshot row matched the fingerprint bind but failed to deserialize
13    /// into `S`. Never silently ignored, unlike a fingerprint mismatch — the
14    /// operator fix is `DELETE FROM <tbl>_snapshots WHERE id = …`.
15    #[error("EntityHydrationError - SnapshotDecode at sequence {sequence}: {source}")]
16    SnapshotDecode {
17        sequence: i32,
18        #[source]
19        source: serde_json::Error,
20    },
21    /// The first tail event after a snapshot did not immediately follow it.
22    #[error(
23        "EntityHydrationError - SnapshotGap: snapshot at sequence {snapshot_sequence}, next event at {next_event_sequence}"
24    )]
25    SnapshotGap {
26        snapshot_sequence: i32,
27        next_event_sequence: i32,
28    },
29    /// A hydration row carried neither an event nor a usable snapshot.
30    #[error("EntityHydrationError - NoEvents")]
31    NoEvents,
32}
33
34#[derive(Error, Debug)]
35#[error("CursorDestructureError: couldn't turn {0} into {1}")]
36pub struct CursorDestructureError(&'static str, &'static str);
37
38impl From<(&'static str, &'static str)> for CursorDestructureError {
39    fn from((name, variant): (&'static str, &'static str)) -> Self {
40        Self(name, variant)
41    }
42}
43
44#[doc(hidden)]
45/// Extracts the conflicting value from a PostgreSQL constraint violation detail message.
46///
47/// PostgreSQL formats unique violation details as:
48/// `Key (column)=(value) already exists.`
49///
50/// Returns `None` if the detail is missing or doesn't match the expected format.
51///
52/// **Security note:** the extracted value is attacker-influenced input that
53/// was rejected by a unique constraint and may be PII (e.g. an email
54/// address). Returning it to untrusted API clients enables user
55/// enumeration; logging it may place PII in log pipelines.
56pub fn parse_constraint_detail_value(detail: Option<&str>) -> Option<String> {
57    let detail = detail?;
58    let start = detail.find("=(")? + 2;
59    let end = detail.rfind(") already")?;
60    if start <= end {
61        Some(detail[start..end].to_string())
62    } else {
63        None
64    }
65}
66
67#[doc(hidden)]
68/// Extracts the conflicting value from a database error's constraint violation.
69///
70/// Downcasts to [`sqlx::postgres::PgDatabaseError`], reads its `detail()`,
71/// and parses the conflicting value.
72///
73/// **Security note:** see [`parse_constraint_detail_value`] — the returned
74/// value may be PII and must not be exposed to untrusted clients.
75pub fn extract_constraint_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
76    let pg_err = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()?;
77    parse_constraint_detail_value(pg_err.detail())
78}
79
80#[doc(hidden)]
81/// Extracts the conflicting id from an events-table primary-key violation.
82///
83/// The events tables' primary key is `(id, sequence)`, so the violation
84/// detail reads `Key (id, sequence)=(<id>, <seq>) already exists.` — the id
85/// is everything before the last `, `. The sequence is an integer and can
86/// never contain `, `, so splitting at the last occurrence is unambiguous
87/// even for ids that themselves contain commas.
88///
89/// **Security note:** see [`parse_constraint_detail_value`] — the returned
90/// value may be PII and must not be exposed to untrusted clients.
91pub fn extract_events_pkey_id_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
92    events_pkey_id_from_value(extract_constraint_value(db_err)?)
93}
94
95fn events_pkey_id_from_value(value: String) -> Option<String> {
96    match value.rsplit_once(", ") {
97        Some((id, _sequence)) => Some(id.to_string()),
98        None => Some(value),
99    }
100}
101
102/// The kind of database constraint behind a classified `ConstraintViolation`.
103///
104/// Returned by the generated `{Entity}Constraint::kind()` method.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ConstraintKind {
107    Unique,
108    ForeignKey,
109    Check,
110}
111
112#[doc(hidden)]
113/// `true` when the database error is a violation kind the generated repo
114/// classifiers surface as `ConstraintViolation`: unique (SQLSTATE 23505),
115/// foreign key (23503), or check (23514). `NOT NULL` (23502) and exclusion
116/// (23P01) violations are not classified and surface as `Sqlx`.
117pub fn is_classified_constraint_violation(db_err: &dyn sqlx::error::DatabaseError) -> bool {
118    db_err.is_unique_violation() || db_err.is_foreign_key_violation() || db_err.is_check_violation()
119}
120
121#[doc(hidden)]
122/// Wrapper used by generated code to format not-found values.
123/// Prefers `Display` over `Debug` via inherent-vs-trait method resolution.
124pub struct NotFoundValue<'a, T: ?Sized>(pub &'a T);
125
126impl<T: std::fmt::Display + ?Sized> NotFoundValue<'_, T> {
127    pub fn to_not_found_value(&self) -> String {
128        self.0.to_string()
129    }
130}
131
132#[doc(hidden)]
133pub trait ToNotFoundValueFallback {
134    fn to_not_found_value(&self) -> String;
135}
136
137impl<T: std::fmt::Debug + ?Sized> ToNotFoundValueFallback for NotFoundValue<'_, T> {
138    fn to_not_found_value(&self) -> String {
139        format!("{:?}", self.0)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use proptest::prelude::*;
147
148    #[test]
149    fn parse_simple_uuid_value() {
150        let detail = Some("Key (id)=(550e8400-e29b-41d4-a716-446655440000) already exists.");
151        assert_eq!(
152            parse_constraint_detail_value(detail),
153            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
154        );
155    }
156
157    #[test]
158    fn parse_string_value() {
159        let detail = Some("Key (email)=(user@example.com) already exists.");
160        assert_eq!(
161            parse_constraint_detail_value(detail),
162            Some("user@example.com".to_string())
163        );
164    }
165
166    #[test]
167    fn parse_composite_key_value() {
168        let detail = Some("Key (tenant_id, email)=(abc, user@example.com) already exists.");
169        assert_eq!(
170            parse_constraint_detail_value(detail),
171            Some("abc, user@example.com".to_string())
172        );
173    }
174
175    #[test]
176    fn parse_none_detail() {
177        assert_eq!(parse_constraint_detail_value(None), None);
178    }
179
180    #[test]
181    fn parse_unexpected_format() {
182        let detail = Some("something unexpected");
183        assert_eq!(parse_constraint_detail_value(detail), None);
184    }
185
186    #[test]
187    fn parse_value_containing_parentheses() {
188        let detail = Some("Key (name)=(foo (bar)) already exists.");
189        assert_eq!(
190            parse_constraint_detail_value(detail),
191            Some("foo (bar)".to_string())
192        );
193    }
194
195    #[test]
196    fn events_pkey_id_strips_the_sequence() {
197        let value = parse_constraint_detail_value(Some(
198            "Key (id, sequence)=(550e8400-e29b-41d4-a716-446655440000, 1) already exists.",
199        ))
200        .unwrap();
201        assert_eq!(
202            events_pkey_id_from_value(value),
203            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
204        );
205    }
206
207    #[test]
208    fn events_pkey_id_keeps_commas_inside_the_id() {
209        assert_eq!(
210            events_pkey_id_from_value("a, b, 3".to_string()),
211            Some("a, b".to_string())
212        );
213    }
214
215    #[test]
216    fn events_pkey_id_without_separator_returns_value_as_is() {
217        assert_eq!(
218            events_pkey_id_from_value("plain".to_string()),
219            Some("plain".to_string())
220        );
221    }
222
223    #[test]
224    fn parse_empty_value() {
225        let detail = Some("Key (col)=() already exists.");
226        assert_eq!(parse_constraint_detail_value(detail), Some("".to_string()));
227    }
228
229    #[test]
230    fn not_found_value_uses_display_when_available() {
231        #[allow(unused_imports)]
232        use crate::ToNotFoundValueFallback;
233
234        // String implements Display - should get clean output
235        let val = "hello";
236        assert_eq!(NotFoundValue(val).to_not_found_value(), "hello");
237
238        // i32 implements Display
239        let num = 42;
240        assert_eq!(NotFoundValue(&num).to_not_found_value(), "42");
241    }
242
243    #[test]
244    fn not_found_value_falls_back_to_debug() {
245        use crate::ToNotFoundValueFallback;
246
247        // A type with Debug but no Display
248        #[derive(Debug)]
249        #[allow(dead_code)]
250        struct OnlyDebug(i32);
251
252        let val = OnlyDebug(7);
253        assert_eq!(NotFoundValue(&val).to_not_found_value(), "OnlyDebug(7)");
254    }
255
256    proptest! {
257        /// The parser does byte-index arithmetic over attacker-controlled strings.
258        /// It must never panic, and any value it returns must be an honest
259        /// substring bounded by the real markers.
260        #[test]
261        fn constraint_detail_never_panics_and_is_honest(detail in ".*") {
262            match parse_constraint_detail_value(Some(&detail)) {
263                None => {}
264                Some(v) => {
265                    // Returned value is always a genuine substring of the input.
266                    prop_assert!(detail.contains(&v));
267                    // The markers that drove the indices must actually be present.
268                    let start = detail.find("=(").expect("start marker present") + 2;
269                    let end = detail
270                        .rfind(") already")
271                        .expect("end marker present");
272                    prop_assert!(start <= end);
273                    prop_assert_eq!(&detail[start..end], v.as_str());
274                }
275            }
276        }
277
278        #[test]
279        fn constraint_detail_none_input_returns_none(_ in Just(())) {
280            prop_assert_eq!(parse_constraint_detail_value(None), None);
281        }
282    }
283}