Skip to main content

fraiseql_core/runtime/
mutation_result.rs

1//! Mutation response parser for `app.mutation_response` composite rows.
2//!
3//! This module implements the fix for issue #294: error types with scalar/primitive
4//! fields (String, Int, `DateTime`, UUID, etc.) are now correctly populated from the
5//! `metadata` JSONB column, in addition to the existing support for nested object fields.
6
7use std::collections::HashMap;
8
9use serde_json::{Map, Value as JsonValue};
10
11use crate::{
12    error::{FraiseQLError, Result},
13    schema::FieldDefinition,
14};
15
16/// Scalar GraphQL type names that can be populated directly from JSONB values.
17const SCALAR_TYPES: &[&str] = &[
18    "String", "Int", "Float", "Boolean", "ID", "DateTime", "UUID", "Date", "Time",
19];
20
21/// Outcome of parsing a single `mutation_response` row.
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24pub enum MutationOutcome {
25    /// The mutation succeeded; the result entity is available.
26    Success {
27        /// The entity JSONB returned by the function.
28        entity:      JsonValue,
29        /// GraphQL type name for the entity (from the `entity_type` column).
30        entity_type: Option<String>,
31        /// UUID string of the mutated entity (from the `entity_id` column).
32        ///
33        /// Present for UPDATE and DELETE mutations. Used for entity-aware cache
34        /// invalidation: only cache entries containing this UUID are evicted,
35        /// leaving unrelated entries warm.
36        entity_id:   Option<String>,
37        /// Cascade operations associated with this mutation.
38        cascade:     Option<JsonValue>,
39    },
40    /// The mutation failed; error metadata is available.
41    Error {
42        /// Status code (e.g. `"failed:validation"`, `"conflict:duplicate"`).
43        status:   String,
44        /// Human-readable error message.
45        message:  String,
46        /// Structured metadata JSONB containing error-type field values.
47        metadata: JsonValue,
48    },
49}
50
51/// Parse a single row from `execute_function_call` into a `MutationOutcome`.
52///
53/// Expects the row to contain the standard `app.mutation_response` columns:
54/// `status`, `message`, `entity`, `entity_type`, `cascade`, `metadata`.
55///
56/// # Errors
57///
58/// Returns `FraiseQLError::Validation` if the `status` column is missing.
59pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
60    row: &HashMap<String, JsonValue, S>,
61) -> Result<MutationOutcome> {
62    let status = row
63        .get("status")
64        .and_then(|v| v.as_str())
65        .ok_or_else(|| FraiseQLError::Validation {
66            message: "mutation_response row is missing 'status' column".to_string(),
67            path:    None,
68        })?
69        .to_string();
70
71    let message = row.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string();
72
73    if is_error_status(&status) {
74        let metadata = row.get("metadata").cloned().unwrap_or(JsonValue::Null);
75        Ok(MutationOutcome::Error {
76            status,
77            message,
78            metadata,
79        })
80    } else {
81        let entity = row.get("entity").cloned().unwrap_or(JsonValue::Null);
82        let entity_type = row.get("entity_type").and_then(|v| v.as_str()).map(str::to_string);
83        let entity_id = row.get("entity_id").and_then(|v| v.as_str()).map(str::to_string);
84        let cascade = row.get("cascade").cloned().filter(|v| !v.is_null());
85        Ok(MutationOutcome::Success {
86            entity,
87            entity_type,
88            entity_id,
89            cascade,
90        })
91    }
92}
93
94/// Classify a mutation status string as an error.
95///
96/// The following patterns are treated as errors:
97/// - `"failed:*"` — validation, business-rule, or processing failures
98/// - `"conflict:*"` — uniqueness or concurrency conflicts
99/// - `"error"` — generic error status
100pub fn is_error_status(status: &str) -> bool {
101    status.starts_with("failed:") || status.starts_with("conflict:") || status == "error"
102}
103
104/// Populate error-type fields from a `metadata` JSONB object.
105///
106/// This is the fix for issue #294: scalar fields (String, Int, Float, Boolean,
107/// `DateTime`, UUID, …) are now populated directly from the JSON value, without
108/// requiring the value to be a nested object.
109///
110/// Both camelCase and `snake_case` metadata keys are tried for each field.
111///
112/// # Arguments
113///
114/// * `fields` — field definitions from the error `TypeDefinition`
115/// * `metadata` — the raw `metadata` JSON from the mutation response row
116///
117/// # Returns
118///
119/// A JSON object map containing the populated fields.
120pub fn populate_error_fields(
121    fields: &[FieldDefinition],
122    metadata: &JsonValue,
123) -> Map<String, JsonValue> {
124    let mut output = Map::new();
125
126    let Some(obj) = metadata.as_object() else {
127        return output;
128    };
129
130    for field in fields {
131        // Try camelCase first, then the raw field name (snake_case)
132        let camel = to_camel_case(field.name.as_str());
133        let raw_val = obj.get(&camel).or_else(|| obj.get(field.name.as_str()));
134
135        let Some(raw_val) = raw_val else { continue };
136
137        let base_type = strip_list_and_bang(&field.field_type.to_string());
138
139        if SCALAR_TYPES.contains(&base_type.as_str()) {
140            // #294 fix: copy scalar values directly (string, int, datetime, uuid, …)
141            output.insert(field.name.to_string(), raw_val.clone());
142        } else if raw_val.is_object() {
143            // Complex entity field: nested JSON object (existing behaviour)
144            output.insert(field.name.to_string(), raw_val.clone());
145        }
146        // else: non-scalar, non-object value in metadata — skip
147    }
148
149    output
150}
151
152/// Convert a `snake_case` field name to camelCase for metadata key lookup.
153///
154/// Examples: `"last_activity_date"` → `"lastActivityDate"`,
155///            `"cascade_count"` → `"cascadeCount"`.
156fn to_camel_case(snake: &str) -> String {
157    let mut result = String::with_capacity(snake.len());
158    let mut capitalise_next = false;
159
160    for ch in snake.chars() {
161        if ch == '_' {
162            capitalise_next = true;
163        } else if capitalise_next {
164            result.push(ch.to_ascii_uppercase());
165            capitalise_next = false;
166        } else {
167            result.push(ch);
168        }
169    }
170
171    result
172}
173
174/// Strip list wrappers and non-null bangs from a field type string.
175///
176/// Examples:
177/// - `"String!"` → `"String"`
178/// - `"[String!]!"` → `"String"`
179/// - `"DateTime"` → `"DateTime"`
180fn strip_list_and_bang(field_type: &str) -> String {
181    field_type.trim_matches(|c| c == '[' || c == ']' || c == '!').to_string()
182}
183
184#[cfg(test)]
185mod tests {
186    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
187
188    use serde_json::json;
189
190    use super::*;
191    use crate::schema::{FieldDenyPolicy, FieldType};
192
193    fn make_field(name: &str, type_str: &str) -> FieldDefinition {
194        FieldDefinition {
195            name:           name.into(),
196            field_type:     FieldType::parse(type_str),
197            nullable:       true,
198            default_value:  None,
199            description:    None,
200            vector_config:  None,
201            alias:          None,
202            deprecation:    None,
203            requires_scope: None,
204            on_deny:        FieldDenyPolicy::default(),
205            encryption:     None,
206        }
207    }
208
209    #[test]
210    fn test_parse_success_row() {
211        let mut row = HashMap::new();
212        row.insert("status".to_string(), json!("new"));
213        row.insert("message".to_string(), json!("created"));
214        row.insert("entity".to_string(), json!({"id": "abc", "name": "Foo"}));
215        row.insert("entity_type".to_string(), json!("Machine"));
216
217        let outcome = parse_mutation_row(&row).expect("test fixture must parse successfully");
218        assert!(matches!(outcome, MutationOutcome::Success { .. }));
219        if let MutationOutcome::Success {
220            entity,
221            entity_type,
222            entity_id,
223            ..
224        } = outcome
225        {
226            assert_eq!(entity["id"], "abc");
227            assert_eq!(entity_type.as_deref(), Some("Machine"));
228            assert!(entity_id.is_none());
229        }
230    }
231
232    #[test]
233    fn test_parse_mutation_row_includes_entity_id() {
234        let mut row = HashMap::new();
235        row.insert("status".to_string(), json!("updated"));
236        row.insert("message".to_string(), json!("updated"));
237        row.insert("entity".to_string(), json!({"id": "550e8400-e29b-41d4-a716-446655440000"}));
238        row.insert("entity_type".to_string(), json!("User"));
239        row.insert("entity_id".to_string(), json!("550e8400-e29b-41d4-a716-446655440000"));
240
241        let outcome = parse_mutation_row(&row).expect("test fixture must parse successfully");
242        if let MutationOutcome::Success {
243            entity_id,
244            entity_type,
245            ..
246        } = outcome
247        {
248            assert_eq!(entity_id.as_deref(), Some("550e8400-e29b-41d4-a716-446655440000"));
249            assert_eq!(entity_type.as_deref(), Some("User"));
250        } else {
251            panic!("expected Success");
252        }
253    }
254
255    #[test]
256    fn test_parse_mutation_row_entity_id_absent_when_missing() {
257        let mut row = HashMap::new();
258        row.insert("status".to_string(), json!("new"));
259        row.insert("entity".to_string(), json!({"id": "abc"}));
260        // entity_id column not present (CREATE mutation)
261
262        let outcome = parse_mutation_row(&row).expect("test fixture must parse successfully");
263        if let MutationOutcome::Success { entity_id, .. } = outcome {
264            assert!(entity_id.is_none());
265        } else {
266            panic!("expected Success");
267        }
268    }
269
270    #[test]
271    fn test_parse_error_row() {
272        let mut row = HashMap::new();
273        row.insert("status".to_string(), json!("failed:validation"));
274        row.insert("message".to_string(), json!("invalid input"));
275        row.insert("metadata".to_string(), json!({"last_activity_date": "2024-01-01"}));
276
277        let outcome = parse_mutation_row(&row).expect("test fixture must parse successfully");
278        assert!(matches!(outcome, MutationOutcome::Error { .. }));
279        if let MutationOutcome::Error {
280            status, metadata, ..
281        } = outcome
282        {
283            assert_eq!(status, "failed:validation");
284            assert!(metadata.is_object());
285        }
286    }
287
288    #[test]
289    fn test_populate_scalar_string() {
290        let fields = vec![make_field("reason", "String")];
291        let metadata = json!({"reason": "some error"});
292
293        let result = populate_error_fields(&fields, &metadata);
294        assert_eq!(result["reason"], "some error");
295    }
296
297    #[test]
298    fn test_populate_scalar_int() {
299        let fields = vec![make_field("cascade_count", "Int")];
300        let metadata = json!({"cascade_count": 42});
301
302        let result = populate_error_fields(&fields, &metadata);
303        assert_eq!(result["cascade_count"], 42);
304    }
305
306    #[test]
307    fn test_populate_scalar_datetime() {
308        let fields = vec![make_field("last_activity_date", "DateTime")];
309        let metadata = json!({"last_activity_date": "2024-06-01T12:00:00Z"});
310
311        let result = populate_error_fields(&fields, &metadata);
312        assert_eq!(result["last_activity_date"], "2024-06-01T12:00:00Z");
313    }
314
315    #[test]
316    fn test_populate_scalar_uuid() {
317        let fields = vec![make_field("entity_id", "UUID")];
318        let metadata = json!({"entity_id": "550e8400-e29b-41d4-a716-446655440000"});
319
320        let result = populate_error_fields(&fields, &metadata);
321        assert_eq!(result["entity_id"], "550e8400-e29b-41d4-a716-446655440000");
322    }
323
324    #[test]
325    fn test_populate_complex_entity() {
326        let fields = vec![make_field("related", "SomeType")];
327        let metadata = json!({"related": {"id": "xyz", "name": "bar"}});
328
329        let result = populate_error_fields(&fields, &metadata);
330        assert_eq!(result["related"]["id"], "xyz");
331    }
332
333    #[test]
334    fn test_populate_missing_field_is_absent() {
335        let fields = vec![make_field("reason", "String")];
336        let metadata = json!({"other_key": "value"});
337
338        let result = populate_error_fields(&fields, &metadata);
339        assert!(!result.contains_key("reason"));
340    }
341
342    #[test]
343    fn test_camel_case_key_lookup() {
344        // Field name is snake_case; metadata key is camelCase
345        let fields = vec![make_field("last_activity_date", "DateTime")];
346        let metadata = json!({"lastActivityDate": "2024-01-01"});
347
348        let result = populate_error_fields(&fields, &metadata);
349        assert_eq!(result["last_activity_date"], "2024-01-01");
350    }
351
352    #[test]
353    fn test_is_error_status() {
354        assert!(is_error_status("failed:validation"));
355        assert!(is_error_status("failed:business_rule"));
356        assert!(is_error_status("conflict:duplicate"));
357        assert!(is_error_status("conflict:concurrent_update"));
358        assert!(is_error_status("error"));
359        assert!(!is_error_status("new"));
360        assert!(!is_error_status("updated"));
361        assert!(!is_error_status("deleted"));
362        assert!(!is_error_status(""));
363    }
364}