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)]
23#[non_exhaustive]
24pub enum MutationOutcome {
25 Success {
27 entity: JsonValue,
29 entity_type: Option<String>,
31 entity_id: Option<String>,
37 cascade: Option<JsonValue>,
39 },
40 Error {
42 status: String,
44 message: String,
46 metadata: JsonValue,
48 },
49}
50
51pub 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
94pub fn is_error_status(status: &str) -> bool {
101 status.starts_with("failed:") || status.starts_with("conflict:") || status == "error"
102}
103
104pub 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 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 output.insert(field.name.to_string(), raw_val.clone());
142 } else if raw_val.is_object() {
143 output.insert(field.name.to_string(), raw_val.clone());
145 }
146 }
148
149 output
150}
151
152fn 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
174fn 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)] 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 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 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}