Skip to main content

fraiseql_core/runtime/
mutation_result.rs

1//! Mutation response parser for `app.mutation_response` composite rows.
2//!
3//! Parses a typed, column-per-concern row into [`MutationOutcome`], which the
4//! executor uses to build the GraphQL response. The row shape maps 1:1 to the
5//! `app.mutation_response` PostgreSQL composite type — see
6//! `docs/architecture/mutation-response.md` for the DDL and semantics table.
7
8use std::collections::HashMap;
9
10use serde::Deserialize;
11use serde_json::Value as JsonValue;
12use uuid::Uuid;
13
14use super::cascade::MutationErrorClass;
15use crate::error::{FraiseQLError, Result};
16
17/// Minimum legal HTTP status code (informational range start).
18const HTTP_STATUS_MIN: i16 = 100;
19/// Maximum legal HTTP status code (end of 5xx range).
20const HTTP_STATUS_MAX: i16 = 599;
21
22/// Outcome of parsing a single `mutation_response` row.
23#[derive(Debug, Clone)]
24#[non_exhaustive]
25pub enum MutationOutcome {
26    /// The mutation succeeded; the result entity is available.
27    Success {
28        /// The entity JSONB returned by the function.
29        entity:      JsonValue,
30        /// GraphQL type name for the entity (from the `entity_type` column).
31        entity_type: Option<String>,
32        /// UUID string of the mutated entity (from the `entity_id` column).
33        ///
34        /// Present for UPDATE and DELETE mutations. Used for entity-aware cache
35        /// invalidation: only cache entries containing this UUID are evicted,
36        /// leaving unrelated entries warm.
37        entity_id:   Option<String>,
38        /// Cascade operations associated with this mutation.
39        cascade:     Option<JsonValue>,
40    },
41    /// The mutation failed; error metadata is available.
42    Error {
43        /// Typed classification of the failure (mirrors `app.mutation_error_class`).
44        error_class: MutationErrorClass,
45        /// Human-readable error message.
46        message:     String,
47        /// Structured metadata JSONB containing error-type field values.
48        metadata:    JsonValue,
49    },
50}
51
52/// Typed `app.mutation_response` row.
53///
54/// Field types map 1:1 to the PostgreSQL composite columns. See
55/// `docs/architecture/mutation-response.md`.
56#[derive(Debug, Clone, Deserialize)]
57#[non_exhaustive]
58pub struct MutationResponse {
59    /// Terminal outcome. `true` means the operation completed (including noops).
60    pub succeeded:      bool,
61    /// Did the database actually change? Independent of `succeeded`.
62    pub state_changed:  bool,
63    /// `NULL` iff `succeeded`. Drives the cascade error code 1:1.
64    #[serde(default)]
65    pub error_class:    Option<MutationErrorClass>,
66    /// Human-readable subtype (e.g. `"duplicate_email"`); not parsed.
67    #[serde(default)]
68    pub status_detail:  Option<String>,
69    /// HTTP status, first-class. Validated to 100..=599 on ingest.
70    #[serde(default)]
71    pub http_status:    Option<i16>,
72    /// Human-readable summary safe to show to end users.
73    #[serde(default)]
74    pub message:        Option<String>,
75    /// Primary key of the affected entity. Present for updates/deletes.
76    #[serde(default)]
77    pub entity_id:      Option<Uuid>,
78    /// GraphQL type name (e.g. `"User"`). Used for cache invalidation.
79    #[serde(default)]
80    pub entity_type:    Option<String>,
81    /// Full entity payload. Populated even for noops.
82    #[serde(default)]
83    pub entity:         JsonValue,
84    /// GraphQL field names that changed. Empty on noop.
85    #[serde(default)]
86    pub updated_fields: Vec<String>,
87    /// Cascade operations (see the graphql-cascade specification).
88    #[serde(default)]
89    pub cascade:        JsonValue,
90    /// Structured error payload only (field / constraint / severity).
91    #[serde(default)]
92    pub error_detail:   JsonValue,
93    /// Observability only (trace IDs, timings, audit extras).
94    #[serde(default)]
95    pub metadata:       JsonValue,
96}
97
98/// Parse a `mutation_response` row into a [`MutationOutcome`].
99///
100/// Deserializes typed columns directly — no string parsing. Rejects the illegal
101/// combination `succeeded=false AND state_changed=true` (the builder refuses to
102/// construct such a row; defense in depth here so a hand-written SQL path
103/// cannot slip a partial-failure row past the parser).
104///
105/// `error_detail` (not `metadata`) feeds the executor's error-field projection
106/// so downstream consumers remain untouched: `metadata` carries observability
107/// only and must not be used as an error-data carrier.
108///
109/// # Errors
110///
111/// Returns [`FraiseQLError::Validation`] if:
112/// - the row fails to deserialize into [`MutationResponse`];
113/// - `http_status` is outside `100..=599`;
114/// - `succeeded=false` with `state_changed=true` (illegal per the semantics table);
115/// - `succeeded=false` with `error_class` missing.
116pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
117    row: &HashMap<String, JsonValue, S>,
118) -> Result<MutationOutcome> {
119    let obj: serde_json::Map<String, JsonValue> =
120        row.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
121    let parsed: MutationResponse =
122        serde_json::from_value(JsonValue::Object(obj)).map_err(|e| FraiseQLError::Validation {
123            message: format!("mutation_response row failed to deserialize: {e}"),
124            path:    None,
125        })?;
126    to_outcome(parsed)
127}
128
129/// Lower a deserialized [`MutationResponse`] to the shared outcome seam.
130fn to_outcome(row: MutationResponse) -> Result<MutationOutcome> {
131    if let Some(status) = row.http_status {
132        if !(HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&status) {
133            return Err(FraiseQLError::Validation {
134                message: format!(
135                    "mutation_response 'http_status' out of range: {status} \
136                     (expected {HTTP_STATUS_MIN}..={HTTP_STATUS_MAX})"
137                ),
138                path:    None,
139            });
140        }
141    }
142
143    if row.succeeded {
144        if row.error_class.is_some() {
145            return Err(FraiseQLError::Validation {
146                message: "mutation_response: succeeded=true but error_class is set".to_string(),
147                path:    None,
148            });
149        }
150        Ok(MutationOutcome::Success {
151            entity:      row.entity,
152            entity_type: row.entity_type,
153            entity_id:   row.entity_id.map(|u| u.to_string()),
154            cascade:     filter_null(row.cascade),
155        })
156    } else {
157        if row.state_changed {
158            return Err(FraiseQLError::Validation {
159                message: "mutation_response: succeeded=false with state_changed=true is illegal \
160                          (partial-failure rows are builder-rejected)"
161                    .to_string(),
162                path:    None,
163            });
164        }
165        let Some(class) = row.error_class else {
166            return Err(FraiseQLError::Validation {
167                message: "mutation_response: succeeded=false requires error_class".to_string(),
168                path:    None,
169            });
170        };
171        Ok(MutationOutcome::Error {
172            error_class: class,
173            message:     row.message.unwrap_or_default(),
174            metadata:    row.error_detail,
175        })
176    }
177}
178
179fn filter_null(v: JsonValue) -> Option<JsonValue> {
180    if v.is_null() { None } else { Some(v) }
181}
182
183#[cfg(test)]
184mod tests {
185    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
186
187    use serde_json::json;
188
189    use super::*;
190
191    /// Terse builder for constructing row fixtures in tests.
192    #[derive(Default)]
193    struct Row(HashMap<String, JsonValue>);
194
195    impl Row {
196        fn new(succeeded: bool, state_changed: bool) -> Self {
197            let mut r = Self::default();
198            r.0.insert("succeeded".into(), json!(succeeded));
199            r.0.insert("state_changed".into(), json!(state_changed));
200            r
201        }
202
203        fn with(mut self, key: &str, value: JsonValue) -> Self {
204            self.0.insert(key.into(), value);
205            self
206        }
207
208        fn parse(&self) -> Result<MutationOutcome> {
209            parse_mutation_row(&self.0)
210        }
211    }
212
213    // ── Deserialization ────────────────────────────────────────────────────
214
215    #[test]
216    fn deserializes_all_columns() {
217        let eid = "550e8400-e29b-41d4-a716-446655440000";
218        let mut row = HashMap::new();
219        row.insert("succeeded".to_string(), json!(false));
220        row.insert("state_changed".to_string(), json!(false));
221        row.insert("error_class".to_string(), json!("validation"));
222        row.insert("status_detail".to_string(), json!("duplicate_email"));
223        row.insert("http_status".to_string(), json!(422));
224        row.insert("message".to_string(), json!("email already in use"));
225        row.insert("entity_id".to_string(), json!(eid));
226        row.insert("entity_type".to_string(), json!("User"));
227        row.insert("entity".to_string(), json!({"id": eid}));
228        row.insert("updated_fields".to_string(), json!(["email"]));
229        row.insert("cascade".to_string(), json!({}));
230        row.insert("error_detail".to_string(), json!({"field": "email"}));
231        row.insert("metadata".to_string(), json!({"trace_id": "abc"}));
232
233        let obj: serde_json::Map<String, JsonValue> = row.into_iter().collect();
234        let parsed: MutationResponse = serde_json::from_value(JsonValue::Object(obj)).unwrap();
235
236        assert!(!parsed.succeeded);
237        assert!(!parsed.state_changed);
238        assert_eq!(parsed.error_class, Some(MutationErrorClass::Validation));
239        assert_eq!(parsed.status_detail.as_deref(), Some("duplicate_email"));
240        assert_eq!(parsed.http_status, Some(422));
241        assert_eq!(parsed.message.as_deref(), Some("email already in use"));
242        assert_eq!(parsed.entity_id.map(|u| u.to_string()).as_deref(), Some(eid));
243        assert_eq!(parsed.entity_type.as_deref(), Some("User"));
244        assert_eq!(parsed.updated_fields, vec!["email".to_string()]);
245        assert_eq!(parsed.error_detail["field"], "email");
246        assert_eq!(parsed.metadata["trace_id"], "abc");
247    }
248
249    #[test]
250    fn defaults_missing_jsonb_columns_to_null() {
251        let parsed: MutationResponse = serde_json::from_value(json!({
252            "succeeded": true,
253            "state_changed": false,
254        }))
255        .unwrap();
256        assert!(parsed.entity.is_null());
257        assert!(parsed.cascade.is_null());
258        assert!(parsed.error_detail.is_null());
259        assert!(parsed.metadata.is_null());
260        assert!(parsed.updated_fields.is_empty());
261        assert!(parsed.entity_id.is_none());
262    }
263
264    // ── Semantics table ────────────────────────────────────────────────────
265
266    #[test]
267    fn semantics_success_state_changed_true() {
268        let entity = json!({"id": "x"});
269        let outcome = Row::new(true, true)
270            .with("entity", entity.clone())
271            .with("entity_type", json!("Machine"))
272            .parse()
273            .unwrap();
274        match outcome {
275            MutationOutcome::Success {
276                entity: e,
277                entity_type,
278                entity_id,
279                cascade,
280            } => {
281                assert_eq!(e, entity);
282                assert_eq!(entity_type.as_deref(), Some("Machine"));
283                assert!(entity_id.is_none());
284                assert!(cascade.is_none());
285            },
286            MutationOutcome::Error { .. } => panic!("expected Success"),
287        }
288    }
289
290    #[test]
291    fn semantics_success_noop() {
292        let entity = json!({"id": "x", "name": "current"});
293        let outcome = Row::new(true, false).with("entity", entity.clone()).parse().unwrap();
294        match outcome {
295            MutationOutcome::Success { entity: e, .. } => assert_eq!(e, entity),
296            MutationOutcome::Error { .. } => panic!("expected Success (noop)"),
297        }
298    }
299
300    #[test]
301    fn semantics_error_routes_to_error_outcome() {
302        let outcome = Row::new(false, false)
303            .with("error_class", json!("conflict"))
304            .with("message", json!("duplicate"))
305            .with("error_detail", json!({"field": "email"}))
306            .with("metadata", json!({"trace_id": "zzz"}))
307            .parse()
308            .unwrap();
309        match outcome {
310            MutationOutcome::Error {
311                error_class,
312                message,
313                metadata,
314            } => {
315                assert_eq!(error_class, MutationErrorClass::Conflict);
316                assert_eq!(message, "duplicate");
317                // error_detail (not metadata) feeds the error-field projection.
318                assert_eq!(metadata, json!({"field": "email"}));
319            },
320            MutationOutcome::Success { .. } => panic!("expected Error"),
321        }
322    }
323
324    #[test]
325    fn semantics_illegal_partial_failure_rejected() {
326        let err = Row::new(false, true)
327            .with("error_class", json!("internal"))
328            .parse()
329            .expect_err("partial failure must be rejected");
330        match err {
331            FraiseQLError::Validation { message, .. } => {
332                assert!(message.contains("state_changed=true is illegal"), "got: {message}");
333            },
334            other => panic!("expected Validation error, got {other:?}"),
335        }
336    }
337
338    #[test]
339    fn error_requires_error_class() {
340        let err = Row::new(false, false)
341            .parse()
342            .expect_err("error row without error_class must be rejected");
343        assert!(matches!(err, FraiseQLError::Validation { .. }));
344    }
345
346    #[test]
347    fn success_rejects_error_class() {
348        let err = Row::new(true, true)
349            .with("error_class", json!("validation"))
350            .parse()
351            .expect_err("succeeded=true with error_class must be rejected");
352        assert!(matches!(err, FraiseQLError::Validation { .. }));
353    }
354
355    #[test]
356    fn http_status_range_enforced() {
357        let err = Row::new(true, false)
358            .with("http_status", json!(42))
359            .parse()
360            .expect_err("http_status out of range must be rejected");
361        match err {
362            FraiseQLError::Validation { message, .. } => {
363                assert!(message.contains("http_status"), "got: {message}");
364            },
365            other => panic!("expected Validation error, got {other:?}"),
366        }
367    }
368
369    #[test]
370    fn http_status_boundaries_accepted() {
371        for code in [100_i16, 200, 422, 599] {
372            Row::new(true, false)
373                .with("http_status", json!(code))
374                .parse()
375                .unwrap_or_else(|e| panic!("code {code} should be accepted: {e:?}"));
376        }
377    }
378
379    #[test]
380    fn as_str_round_trips_all_error_classes() {
381        let cases = [
382            (MutationErrorClass::Validation, "validation"),
383            (MutationErrorClass::Conflict, "conflict"),
384            (MutationErrorClass::NotFound, "not_found"),
385            (MutationErrorClass::Unauthorized, "unauthorized"),
386            (MutationErrorClass::Forbidden, "forbidden"),
387            (MutationErrorClass::Internal, "internal"),
388            (MutationErrorClass::TransactionFailed, "transaction_failed"),
389            (MutationErrorClass::Timeout, "timeout"),
390            (MutationErrorClass::RateLimited, "rate_limited"),
391            (MutationErrorClass::ServiceUnavailable, "service_unavailable"),
392        ];
393        for (class, expected) in cases {
394            assert_eq!(class.as_str(), expected, "class = {class:?}");
395        }
396    }
397
398    #[test]
399    fn entity_id_uuid_serialized_back_to_canonical_string() {
400        let eid = "550e8400-e29b-41d4-a716-446655440000";
401        let outcome = Row::new(true, true)
402            .with("entity_id", json!(eid))
403            .with("entity", json!({"id": eid}))
404            .parse()
405            .unwrap();
406        match outcome {
407            MutationOutcome::Success { entity_id, .. } => {
408                assert_eq!(entity_id.as_deref(), Some(eid));
409            },
410            MutationOutcome::Error { .. } => panic!("expected Success"),
411        }
412    }
413
414    #[test]
415    fn extra_columns_ignored() {
416        // Rows may contain columns the parser doesn't know about (e.g. schema_version
417        // from older DB functions). These must be silently ignored.
418        let outcome = Row::new(true, true)
419            .with("entity", json!({"id": "1"}))
420            .with("schema_version", json!(2))
421            .with("some_future_column", json!("whatever"))
422            .parse()
423            .unwrap();
424        assert!(matches!(outcome, MutationOutcome::Success { .. }));
425    }
426}