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)]
23pub enum MutationOutcome {
24    /// The mutation succeeded; the result entity is available.
25    Success {
26        /// The entity JSONB returned by the function.
27        entity:      JsonValue,
28        /// GraphQL type name for the entity (from the `entity_type` column).
29        entity_type: Option<String>,
30        /// Cascade operations associated with this mutation.
31        cascade:     Option<JsonValue>,
32    },
33    /// The mutation failed; error metadata is available.
34    Error {
35        /// Status code (e.g. `"failed:validation"`, `"conflict:duplicate"`).
36        status:   String,
37        /// Human-readable error message.
38        message:  String,
39        /// Structured metadata JSONB containing error-type field values.
40        metadata: JsonValue,
41    },
42}
43
44/// Parse a single row from `execute_function_call` into a `MutationOutcome`.
45///
46/// Expects the row to contain the standard `app.mutation_response` columns:
47/// `status`, `message`, `entity`, `entity_type`, `cascade`, `metadata`.
48///
49/// # Errors
50///
51/// Returns `FraiseQLError::Validation` if the `status` column is missing.
52pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
53    row: &HashMap<String, JsonValue, S>,
54) -> Result<MutationOutcome> {
55    let status = row
56        .get("status")
57        .and_then(|v| v.as_str())
58        .ok_or_else(|| FraiseQLError::Validation {
59            message: "mutation_response row is missing 'status' column".to_string(),
60            path:    None,
61        })?
62        .to_string();
63
64    let message = row
65        .get("message")
66        .and_then(|v| v.as_str())
67        .unwrap_or("")
68        .to_string();
69
70    if is_error_status(&status) {
71        let metadata = row.get("metadata").cloned().unwrap_or(JsonValue::Null);
72        Ok(MutationOutcome::Error { status, message, metadata })
73    } else {
74        let entity = row.get("entity").cloned().unwrap_or(JsonValue::Null);
75        let entity_type = row.get("entity_type").and_then(|v| v.as_str()).map(str::to_string);
76        let cascade = row.get("cascade").cloned().filter(|v| !v.is_null());
77        Ok(MutationOutcome::Success { entity, entity_type, cascade })
78    }
79}
80
81/// Classify a mutation status string as an error.
82///
83/// The following patterns are treated as errors:
84/// - `"failed:*"` — validation, business-rule, or processing failures
85/// - `"conflict:*"` — uniqueness or concurrency conflicts
86/// - `"error"` — generic error status
87pub fn is_error_status(status: &str) -> bool {
88    status.starts_with("failed:") || status.starts_with("conflict:") || status == "error"
89}
90
91/// Populate error-type fields from a `metadata` JSONB object.
92///
93/// This is the fix for issue #294: scalar fields (String, Int, Float, Boolean,
94/// DateTime, UUID, …) are now populated directly from the JSON value, without
95/// requiring the value to be a nested object.
96///
97/// Both camelCase and snake_case metadata keys are tried for each field.
98///
99/// # Arguments
100///
101/// * `fields` — field definitions from the error `TypeDefinition`
102/// * `metadata` — the raw `metadata` JSON from the mutation response row
103///
104/// # Returns
105///
106/// A JSON object map containing the populated fields.
107pub fn populate_error_fields(
108    fields: &[FieldDefinition],
109    metadata: &JsonValue,
110) -> Map<String, JsonValue> {
111    let mut output = Map::new();
112
113    let Some(obj) = metadata.as_object() else { return output };
114
115    for field in fields {
116        // Try camelCase first, then the raw field name (snake_case)
117        let camel = to_camel_case(&field.name);
118        let raw_val = obj.get(&camel).or_else(|| obj.get(&field.name));
119
120        let Some(raw_val) = raw_val else { continue };
121
122        let base_type = strip_list_and_bang(&field.field_type.to_string());
123
124        if SCALAR_TYPES.contains(&base_type.as_str()) {
125            // #294 fix: copy scalar values directly (string, int, datetime, uuid, …)
126            output.insert(field.name.clone(), raw_val.clone());
127        } else if raw_val.is_object() {
128            // Complex entity field: nested JSON object (existing behaviour)
129            output.insert(field.name.clone(), raw_val.clone());
130        }
131        // else: non-scalar, non-object value in metadata — skip
132    }
133
134    output
135}
136
137/// Convert a snake_case field name to camelCase for metadata key lookup.
138///
139/// Examples: `"last_activity_date"` → `"lastActivityDate"`,
140///            `"cascade_count"` → `"cascadeCount"`.
141fn to_camel_case(snake: &str) -> String {
142    let mut result = String::with_capacity(snake.len());
143    let mut capitalise_next = false;
144
145    for ch in snake.chars() {
146        if ch == '_' {
147            capitalise_next = true;
148        } else if capitalise_next {
149            result.push(ch.to_ascii_uppercase());
150            capitalise_next = false;
151        } else {
152            result.push(ch);
153        }
154    }
155
156    result
157}
158
159/// Strip list wrappers and non-null bangs from a field type string.
160///
161/// Examples:
162/// - `"String!"` → `"String"`
163/// - `"[String!]!"` → `"String"`
164/// - `"DateTime"` → `"DateTime"`
165fn strip_list_and_bang(field_type: &str) -> String {
166    field_type
167        .trim_matches(|c| c == '[' || c == ']' || c == '!')
168        .to_string()
169}
170
171#[cfg(test)]
172mod tests {
173    use serde_json::json;
174
175    use super::*;
176    use crate::schema::FieldType;
177
178    fn make_field(name: &str, type_str: &str) -> FieldDefinition {
179        let known = std::collections::HashSet::new();
180        FieldDefinition {
181            name:           name.to_string(),
182            field_type:     FieldType::parse(type_str, &known),
183            nullable:       true,
184            default_value:  None,
185            description:    None,
186            vector_config:  None,
187            alias:          None,
188            deprecation:    None,
189            requires_scope: None,
190            encryption:     None,
191        }
192    }
193
194    #[test]
195    fn test_parse_success_row() {
196        let mut row = HashMap::new();
197        row.insert("status".to_string(), json!("new"));
198        row.insert("message".to_string(), json!("created"));
199        row.insert("entity".to_string(), json!({"id": "abc", "name": "Foo"}));
200        row.insert("entity_type".to_string(), json!("Machine"));
201
202        let outcome = parse_mutation_row(&row).unwrap();
203        assert!(matches!(outcome, MutationOutcome::Success { .. }));
204        if let MutationOutcome::Success { entity, entity_type, .. } = outcome {
205            assert_eq!(entity["id"], "abc");
206            assert_eq!(entity_type.as_deref(), Some("Machine"));
207        }
208    }
209
210    #[test]
211    fn test_parse_error_row() {
212        let mut row = HashMap::new();
213        row.insert("status".to_string(), json!("failed:validation"));
214        row.insert("message".to_string(), json!("invalid input"));
215        row.insert("metadata".to_string(), json!({"last_activity_date": "2024-01-01"}));
216
217        let outcome = parse_mutation_row(&row).unwrap();
218        assert!(matches!(outcome, MutationOutcome::Error { .. }));
219        if let MutationOutcome::Error { status, metadata, .. } = outcome {
220            assert_eq!(status, "failed:validation");
221            assert!(metadata.is_object());
222        }
223    }
224
225    #[test]
226    fn test_populate_scalar_string() {
227        let fields = vec![make_field("reason", "String")];
228        let metadata = json!({"reason": "some error"});
229
230        let result = populate_error_fields(&fields, &metadata);
231        assert_eq!(result["reason"], "some error");
232    }
233
234    #[test]
235    fn test_populate_scalar_int() {
236        let fields = vec![make_field("cascade_count", "Int")];
237        let metadata = json!({"cascade_count": 42});
238
239        let result = populate_error_fields(&fields, &metadata);
240        assert_eq!(result["cascade_count"], 42);
241    }
242
243    #[test]
244    fn test_populate_scalar_datetime() {
245        let fields = vec![make_field("last_activity_date", "DateTime")];
246        let metadata = json!({"last_activity_date": "2024-06-01T12:00:00Z"});
247
248        let result = populate_error_fields(&fields, &metadata);
249        assert_eq!(result["last_activity_date"], "2024-06-01T12:00:00Z");
250    }
251
252    #[test]
253    fn test_populate_scalar_uuid() {
254        let fields = vec![make_field("entity_id", "UUID")];
255        let metadata = json!({"entity_id": "550e8400-e29b-41d4-a716-446655440000"});
256
257        let result = populate_error_fields(&fields, &metadata);
258        assert_eq!(result["entity_id"], "550e8400-e29b-41d4-a716-446655440000");
259    }
260
261    #[test]
262    fn test_populate_complex_entity() {
263        let fields = vec![make_field("related", "SomeType")];
264        let metadata = json!({"related": {"id": "xyz", "name": "bar"}});
265
266        let result = populate_error_fields(&fields, &metadata);
267        assert_eq!(result["related"]["id"], "xyz");
268    }
269
270    #[test]
271    fn test_populate_missing_field_is_absent() {
272        let fields = vec![make_field("reason", "String")];
273        let metadata = json!({"other_key": "value"});
274
275        let result = populate_error_fields(&fields, &metadata);
276        assert!(!result.contains_key("reason"));
277    }
278
279    #[test]
280    fn test_camel_case_key_lookup() {
281        // Field name is snake_case; metadata key is camelCase
282        let fields = vec![make_field("last_activity_date", "DateTime")];
283        let metadata = json!({"lastActivityDate": "2024-01-01"});
284
285        let result = populate_error_fields(&fields, &metadata);
286        assert_eq!(result["last_activity_date"], "2024-01-01");
287    }
288
289    #[test]
290    fn test_is_error_status() {
291        assert!(is_error_status("failed:validation"));
292        assert!(is_error_status("failed:business_rule"));
293        assert!(is_error_status("conflict:duplicate"));
294        assert!(is_error_status("conflict:concurrent_update"));
295        assert!(is_error_status("error"));
296        assert!(!is_error_status("new"));
297        assert!(!is_error_status("updated"));
298        assert!(!is_error_status("deleted"));
299        assert!(!is_error_status(""));
300    }
301}