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, Deserializer};
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 /// Concrete GraphQL type name for the failure (from the `entity_type`
54 /// column). On the error path a function stamps the declared error type it
55 /// produced (e.g. `"DuplicateEmailError"`), letting the executor route the
56 /// result onto the right `is_error` union member — symmetric with the
57 /// success arm's use of `entity_type` (#465).
58 entity_type: Option<String>,
59 /// Structured metadata JSONB containing error-type field values.
60 metadata: JsonValue,
61 },
62}
63
64/// Typed `app.mutation_response` row.
65///
66/// Field types map 1:1 to the PostgreSQL composite columns. See
67/// `docs/architecture/mutation-response.md`.
68#[derive(Debug, Clone, Deserialize)]
69#[non_exhaustive]
70pub struct MutationResponse {
71 /// Terminal outcome. `true` means the operation completed (including noops).
72 pub succeeded: bool,
73 /// Did the database actually change? Independent of `succeeded`.
74 pub state_changed: bool,
75 /// `NULL` iff `succeeded`. Drives the cascade error code 1:1.
76 #[serde(default)]
77 pub error_class: Option<MutationErrorClass>,
78 /// Human-readable subtype (e.g. `"duplicate_email"`); not parsed.
79 #[serde(default)]
80 pub status_detail: Option<String>,
81 /// HTTP status, first-class. Validated to 100..=599 on ingest.
82 #[serde(default)]
83 pub http_status: Option<i16>,
84 /// Human-readable summary safe to show to end users.
85 #[serde(default)]
86 pub message: Option<String>,
87 /// Primary key of the affected entity. Present for updates/deletes.
88 #[serde(default)]
89 pub entity_id: Option<Uuid>,
90 /// GraphQL type name (e.g. `"User"`). Used for cache invalidation.
91 #[serde(default)]
92 pub entity_type: Option<String>,
93 /// Full entity payload. Populated even for noops.
94 #[serde(default)]
95 pub entity: JsonValue,
96 /// GraphQL field names that changed. Empty on noop. A SQL-`NULL` column
97 /// (rendered by `row_to_map` as JSON `null`) is read as the empty list — see
98 /// `null_as_empty_string_vec`.
99 #[serde(default, deserialize_with = "null_as_empty_string_vec")]
100 pub updated_fields: Vec<String>,
101 /// Cascade operations (see the graphql-cascade specification).
102 #[serde(default)]
103 pub cascade: JsonValue,
104 /// Structured error payload only (field / constraint / severity).
105 #[serde(default)]
106 pub error_detail: JsonValue,
107 /// Observability only (trace IDs, timings, audit extras).
108 #[serde(default)]
109 pub metadata: JsonValue,
110}
111
112/// Deserialize a possibly-`null` `TEXT[]` column as an empty `Vec`.
113///
114/// A failed mutation's function commonly leaves `updated_fields` unset (SQL NULL),
115/// which `row_to_map` renders as JSON `null`. Serde's `#[serde(default)]` only fills
116/// an *absent* key, so an explicit null still reaches `Vec<String>`'s deserializer
117/// and fails with `invalid type: null, expected a sequence` — turning every such
118/// failure into an opaque parse error before the typed error arm is reached (#473).
119/// Treating null as the empty list matches the absent-key behaviour.
120fn null_as_empty_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
121where
122 D: Deserializer<'de>,
123{
124 Ok(Option::<Vec<String>>::deserialize(deserializer)?.unwrap_or_default())
125}
126
127/// Parse a `mutation_response` row into a [`MutationOutcome`].
128///
129/// Deserializes typed columns directly — no string parsing. Rejects the illegal
130/// combination `succeeded=false AND state_changed=true` (the builder refuses to
131/// construct such a row; defense in depth here so a hand-written SQL path
132/// cannot slip a partial-failure row past the parser).
133///
134/// `error_detail` (not `metadata`) feeds the executor's error-field projection
135/// so downstream consumers remain untouched: `metadata` carries observability
136/// only and must not be used as an error-data carrier.
137///
138/// # Errors
139///
140/// Returns [`FraiseQLError::Validation`] if:
141/// - the row fails to deserialize into [`MutationResponse`];
142/// - `http_status` is outside `100..=599`;
143/// - `succeeded=false` with `state_changed=true` (illegal per the semantics table);
144/// - `succeeded=false` with `error_class` missing.
145pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
146 row: &HashMap<String, JsonValue, S>,
147) -> Result<MutationOutcome> {
148 let obj: serde_json::Map<String, JsonValue> =
149 row.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
150 let parsed: MutationResponse =
151 serde_json::from_value(JsonValue::Object(obj)).map_err(|e| FraiseQLError::Validation {
152 message: format!("mutation_response row failed to deserialize: {e}"),
153 path: None,
154 })?;
155 to_outcome(parsed)
156}
157
158/// Lower a deserialized [`MutationResponse`] to the shared outcome seam.
159fn to_outcome(row: MutationResponse) -> Result<MutationOutcome> {
160 if let Some(status) = row.http_status {
161 if !(HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&status) {
162 return Err(FraiseQLError::Validation {
163 message: format!(
164 "mutation_response 'http_status' out of range: {status} \
165 (expected {HTTP_STATUS_MIN}..={HTTP_STATUS_MAX})"
166 ),
167 path: None,
168 });
169 }
170 }
171
172 if row.succeeded {
173 if row.error_class.is_some() {
174 return Err(FraiseQLError::Validation {
175 message: "mutation_response: succeeded=true but error_class is set".to_string(),
176 path: None,
177 });
178 }
179 Ok(MutationOutcome::Success {
180 entity: row.entity,
181 entity_type: row.entity_type,
182 entity_id: row.entity_id.map(|u| u.to_string()),
183 cascade: filter_null(row.cascade),
184 updated_fields: row.updated_fields,
185 })
186 } else {
187 if row.state_changed {
188 return Err(FraiseQLError::Validation {
189 message: "mutation_response: succeeded=false with state_changed=true is illegal \
190 (partial-failure rows are builder-rejected)"
191 .to_string(),
192 path: None,
193 });
194 }
195 let Some(class) = row.error_class else {
196 return Err(FraiseQLError::Validation {
197 message: "mutation_response: succeeded=false requires error_class".to_string(),
198 path: None,
199 });
200 };
201 Ok(MutationOutcome::Error {
202 error_class: class,
203 message: row.message.unwrap_or_default(),
204 http_status: row.http_status,
205 entity_type: row.entity_type,
206 metadata: row.error_detail,
207 })
208 }
209}
210
211fn filter_null(v: JsonValue) -> Option<JsonValue> {
212 if v.is_null() { None } else { Some(v) }
213}