1use std::collections::HashMap;
8
9use serde_json::{Map, Value as JsonValue};
10
11use crate::{
12 error::{FraiseQLError, Result},
13 schema::FieldDefinition,
14};
15
16const SCALAR_TYPES: &[&str] = &[
18 "String", "Int", "Float", "Boolean", "ID", "DateTime", "UUID", "Date", "Time",
19];
20
21#[derive(Debug, Clone)]
23pub enum MutationOutcome {
24 Success {
26 entity: JsonValue,
28 entity_type: Option<String>,
30 cascade: Option<JsonValue>,
32 },
33 Error {
35 status: String,
37 message: String,
39 metadata: JsonValue,
41 },
42}
43
44pub 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
81pub fn is_error_status(status: &str) -> bool {
88 status.starts_with("failed:") || status.starts_with("conflict:") || status == "error"
89}
90
91pub 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 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 output.insert(field.name.clone(), raw_val.clone());
127 } else if raw_val.is_object() {
128 output.insert(field.name.clone(), raw_val.clone());
130 }
131 }
133
134 output
135}
136
137fn 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
159fn 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 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}