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        /// GraphQL field names changed by this mutation (from the `updated_fields`
41        /// column; empty on noop). Surfaced selection-gated as `updatedFields` on
42        /// the success arm, symmetric with `cascade` (#433).
43        updated_fields: Vec<String>,
44    },
45    /// The mutation failed; error metadata is available.
46    Error {
47        /// Typed classification of the failure (mirrors `app.mutation_error_class`).
48        error_class: MutationErrorClass,
49        /// Human-readable error message.
50        message:     String,
51        /// Suggested HTTP status code, when the composite supplied one.
52        http_status: Option<i16>,
53        /// Structured metadata JSONB containing error-type field values.
54        metadata:    JsonValue,
55    },
56}
57
58/// Typed `app.mutation_response` row.
59///
60/// Field types map 1:1 to the PostgreSQL composite columns. See
61/// `docs/architecture/mutation-response.md`.
62#[derive(Debug, Clone, Deserialize)]
63#[non_exhaustive]
64pub struct MutationResponse {
65    /// Terminal outcome. `true` means the operation completed (including noops).
66    pub succeeded:      bool,
67    /// Did the database actually change? Independent of `succeeded`.
68    pub state_changed:  bool,
69    /// `NULL` iff `succeeded`. Drives the cascade error code 1:1.
70    #[serde(default)]
71    pub error_class:    Option<MutationErrorClass>,
72    /// Human-readable subtype (e.g. `"duplicate_email"`); not parsed.
73    #[serde(default)]
74    pub status_detail:  Option<String>,
75    /// HTTP status, first-class. Validated to 100..=599 on ingest.
76    #[serde(default)]
77    pub http_status:    Option<i16>,
78    /// Human-readable summary safe to show to end users.
79    #[serde(default)]
80    pub message:        Option<String>,
81    /// Primary key of the affected entity. Present for updates/deletes.
82    #[serde(default)]
83    pub entity_id:      Option<Uuid>,
84    /// GraphQL type name (e.g. `"User"`). Used for cache invalidation.
85    #[serde(default)]
86    pub entity_type:    Option<String>,
87    /// Full entity payload. Populated even for noops.
88    #[serde(default)]
89    pub entity:         JsonValue,
90    /// GraphQL field names that changed. Empty on noop.
91    #[serde(default)]
92    pub updated_fields: Vec<String>,
93    /// Cascade operations (see the graphql-cascade specification).
94    #[serde(default)]
95    pub cascade:        JsonValue,
96    /// Structured error payload only (field / constraint / severity).
97    #[serde(default)]
98    pub error_detail:   JsonValue,
99    /// Observability only (trace IDs, timings, audit extras).
100    #[serde(default)]
101    pub metadata:       JsonValue,
102}
103
104/// Parse a `mutation_response` row into a [`MutationOutcome`].
105///
106/// Deserializes typed columns directly — no string parsing. Rejects the illegal
107/// combination `succeeded=false AND state_changed=true` (the builder refuses to
108/// construct such a row; defense in depth here so a hand-written SQL path
109/// cannot slip a partial-failure row past the parser).
110///
111/// `error_detail` (not `metadata`) feeds the executor's error-field projection
112/// so downstream consumers remain untouched: `metadata` carries observability
113/// only and must not be used as an error-data carrier.
114///
115/// # Errors
116///
117/// Returns [`FraiseQLError::Validation`] if:
118/// - the row fails to deserialize into [`MutationResponse`];
119/// - `http_status` is outside `100..=599`;
120/// - `succeeded=false` with `state_changed=true` (illegal per the semantics table);
121/// - `succeeded=false` with `error_class` missing.
122pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
123    row: &HashMap<String, JsonValue, S>,
124) -> Result<MutationOutcome> {
125    let obj: serde_json::Map<String, JsonValue> =
126        row.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
127    let parsed: MutationResponse =
128        serde_json::from_value(JsonValue::Object(obj)).map_err(|e| FraiseQLError::Validation {
129            message: format!("mutation_response row failed to deserialize: {e}"),
130            path:    None,
131        })?;
132    to_outcome(parsed)
133}
134
135/// Lower a deserialized [`MutationResponse`] to the shared outcome seam.
136fn to_outcome(row: MutationResponse) -> Result<MutationOutcome> {
137    if let Some(status) = row.http_status {
138        if !(HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&status) {
139            return Err(FraiseQLError::Validation {
140                message: format!(
141                    "mutation_response 'http_status' out of range: {status} \
142                     (expected {HTTP_STATUS_MIN}..={HTTP_STATUS_MAX})"
143                ),
144                path:    None,
145            });
146        }
147    }
148
149    if row.succeeded {
150        if row.error_class.is_some() {
151            return Err(FraiseQLError::Validation {
152                message: "mutation_response: succeeded=true but error_class is set".to_string(),
153                path:    None,
154            });
155        }
156        Ok(MutationOutcome::Success {
157            entity:         row.entity,
158            entity_type:    row.entity_type,
159            entity_id:      row.entity_id.map(|u| u.to_string()),
160            cascade:        filter_null(row.cascade),
161            updated_fields: row.updated_fields,
162        })
163    } else {
164        if row.state_changed {
165            return Err(FraiseQLError::Validation {
166                message: "mutation_response: succeeded=false with state_changed=true is illegal \
167                          (partial-failure rows are builder-rejected)"
168                    .to_string(),
169                path:    None,
170            });
171        }
172        let Some(class) = row.error_class else {
173            return Err(FraiseQLError::Validation {
174                message: "mutation_response: succeeded=false requires error_class".to_string(),
175                path:    None,
176            });
177        };
178        Ok(MutationOutcome::Error {
179            error_class: class,
180            message:     row.message.unwrap_or_default(),
181            http_status: row.http_status,
182            metadata:    row.error_detail,
183        })
184    }
185}
186
187fn filter_null(v: JsonValue) -> Option<JsonValue> {
188    if v.is_null() { None } else { Some(v) }
189}