1use std::collections::HashMap;
9
10use serde::Deserialize;
11use serde_json::Value as JsonValue;
12use uuid::Uuid;
13
14use super::cascade::MutationErrorClass;
15use crate::error::{FraiseQLError, Result};
16
17const HTTP_STATUS_MIN: i16 = 100;
19const HTTP_STATUS_MAX: i16 = 599;
21
22#[derive(Debug, Clone)]
24#[non_exhaustive]
25pub enum MutationOutcome {
26 Success {
28 entity: JsonValue,
30 entity_type: Option<String>,
32 entity_id: Option<String>,
38 cascade: Option<JsonValue>,
40 },
41 Error {
43 error_class: MutationErrorClass,
45 message: String,
47 metadata: JsonValue,
49 },
50}
51
52#[derive(Debug, Clone, Deserialize)]
57#[non_exhaustive]
58pub struct MutationResponse {
59 pub succeeded: bool,
61 pub state_changed: bool,
63 #[serde(default)]
65 pub error_class: Option<MutationErrorClass>,
66 #[serde(default)]
68 pub status_detail: Option<String>,
69 #[serde(default)]
71 pub http_status: Option<i16>,
72 #[serde(default)]
74 pub message: Option<String>,
75 #[serde(default)]
77 pub entity_id: Option<Uuid>,
78 #[serde(default)]
80 pub entity_type: Option<String>,
81 #[serde(default)]
83 pub entity: JsonValue,
84 #[serde(default)]
86 pub updated_fields: Vec<String>,
87 #[serde(default)]
89 pub cascade: JsonValue,
90 #[serde(default)]
92 pub error_detail: JsonValue,
93 #[serde(default)]
95 pub metadata: JsonValue,
96}
97
98pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
117 row: &HashMap<String, JsonValue, S>,
118) -> Result<MutationOutcome> {
119 let obj: serde_json::Map<String, JsonValue> =
120 row.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
121 let parsed: MutationResponse =
122 serde_json::from_value(JsonValue::Object(obj)).map_err(|e| FraiseQLError::Validation {
123 message: format!("mutation_response row failed to deserialize: {e}"),
124 path: None,
125 })?;
126 to_outcome(parsed)
127}
128
129fn to_outcome(row: MutationResponse) -> Result<MutationOutcome> {
131 if let Some(status) = row.http_status {
132 if !(HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&status) {
133 return Err(FraiseQLError::Validation {
134 message: format!(
135 "mutation_response 'http_status' out of range: {status} \
136 (expected {HTTP_STATUS_MIN}..={HTTP_STATUS_MAX})"
137 ),
138 path: None,
139 });
140 }
141 }
142
143 if row.succeeded {
144 if row.error_class.is_some() {
145 return Err(FraiseQLError::Validation {
146 message: "mutation_response: succeeded=true but error_class is set".to_string(),
147 path: None,
148 });
149 }
150 Ok(MutationOutcome::Success {
151 entity: row.entity,
152 entity_type: row.entity_type,
153 entity_id: row.entity_id.map(|u| u.to_string()),
154 cascade: filter_null(row.cascade),
155 })
156 } else {
157 if row.state_changed {
158 return Err(FraiseQLError::Validation {
159 message: "mutation_response: succeeded=false with state_changed=true is illegal \
160 (partial-failure rows are builder-rejected)"
161 .to_string(),
162 path: None,
163 });
164 }
165 let Some(class) = row.error_class else {
166 return Err(FraiseQLError::Validation {
167 message: "mutation_response: succeeded=false requires error_class".to_string(),
168 path: None,
169 });
170 };
171 Ok(MutationOutcome::Error {
172 error_class: class,
173 message: row.message.unwrap_or_default(),
174 metadata: row.error_detail,
175 })
176 }
177}
178
179fn filter_null(v: JsonValue) -> Option<JsonValue> {
180 if v.is_null() { None } else { Some(v) }
181}
182
183#[cfg(test)]
184mod tests {
185 #![allow(clippy::unwrap_used)] use serde_json::json;
188
189 use super::*;
190
191 #[derive(Default)]
193 struct Row(HashMap<String, JsonValue>);
194
195 impl Row {
196 fn new(succeeded: bool, state_changed: bool) -> Self {
197 let mut r = Self::default();
198 r.0.insert("succeeded".into(), json!(succeeded));
199 r.0.insert("state_changed".into(), json!(state_changed));
200 r
201 }
202
203 fn with(mut self, key: &str, value: JsonValue) -> Self {
204 self.0.insert(key.into(), value);
205 self
206 }
207
208 fn parse(&self) -> Result<MutationOutcome> {
209 parse_mutation_row(&self.0)
210 }
211 }
212
213 #[test]
216 fn deserializes_all_columns() {
217 let eid = "550e8400-e29b-41d4-a716-446655440000";
218 let mut row = HashMap::new();
219 row.insert("succeeded".to_string(), json!(false));
220 row.insert("state_changed".to_string(), json!(false));
221 row.insert("error_class".to_string(), json!("validation"));
222 row.insert("status_detail".to_string(), json!("duplicate_email"));
223 row.insert("http_status".to_string(), json!(422));
224 row.insert("message".to_string(), json!("email already in use"));
225 row.insert("entity_id".to_string(), json!(eid));
226 row.insert("entity_type".to_string(), json!("User"));
227 row.insert("entity".to_string(), json!({"id": eid}));
228 row.insert("updated_fields".to_string(), json!(["email"]));
229 row.insert("cascade".to_string(), json!({}));
230 row.insert("error_detail".to_string(), json!({"field": "email"}));
231 row.insert("metadata".to_string(), json!({"trace_id": "abc"}));
232
233 let obj: serde_json::Map<String, JsonValue> = row.into_iter().collect();
234 let parsed: MutationResponse = serde_json::from_value(JsonValue::Object(obj)).unwrap();
235
236 assert!(!parsed.succeeded);
237 assert!(!parsed.state_changed);
238 assert_eq!(parsed.error_class, Some(MutationErrorClass::Validation));
239 assert_eq!(parsed.status_detail.as_deref(), Some("duplicate_email"));
240 assert_eq!(parsed.http_status, Some(422));
241 assert_eq!(parsed.message.as_deref(), Some("email already in use"));
242 assert_eq!(parsed.entity_id.map(|u| u.to_string()).as_deref(), Some(eid));
243 assert_eq!(parsed.entity_type.as_deref(), Some("User"));
244 assert_eq!(parsed.updated_fields, vec!["email".to_string()]);
245 assert_eq!(parsed.error_detail["field"], "email");
246 assert_eq!(parsed.metadata["trace_id"], "abc");
247 }
248
249 #[test]
250 fn defaults_missing_jsonb_columns_to_null() {
251 let parsed: MutationResponse = serde_json::from_value(json!({
252 "succeeded": true,
253 "state_changed": false,
254 }))
255 .unwrap();
256 assert!(parsed.entity.is_null());
257 assert!(parsed.cascade.is_null());
258 assert!(parsed.error_detail.is_null());
259 assert!(parsed.metadata.is_null());
260 assert!(parsed.updated_fields.is_empty());
261 assert!(parsed.entity_id.is_none());
262 }
263
264 #[test]
267 fn semantics_success_state_changed_true() {
268 let entity = json!({"id": "x"});
269 let outcome = Row::new(true, true)
270 .with("entity", entity.clone())
271 .with("entity_type", json!("Machine"))
272 .parse()
273 .unwrap();
274 match outcome {
275 MutationOutcome::Success {
276 entity: e,
277 entity_type,
278 entity_id,
279 cascade,
280 } => {
281 assert_eq!(e, entity);
282 assert_eq!(entity_type.as_deref(), Some("Machine"));
283 assert!(entity_id.is_none());
284 assert!(cascade.is_none());
285 },
286 MutationOutcome::Error { .. } => panic!("expected Success"),
287 }
288 }
289
290 #[test]
291 fn semantics_success_noop() {
292 let entity = json!({"id": "x", "name": "current"});
293 let outcome = Row::new(true, false).with("entity", entity.clone()).parse().unwrap();
294 match outcome {
295 MutationOutcome::Success { entity: e, .. } => assert_eq!(e, entity),
296 MutationOutcome::Error { .. } => panic!("expected Success (noop)"),
297 }
298 }
299
300 #[test]
301 fn semantics_error_routes_to_error_outcome() {
302 let outcome = Row::new(false, false)
303 .with("error_class", json!("conflict"))
304 .with("message", json!("duplicate"))
305 .with("error_detail", json!({"field": "email"}))
306 .with("metadata", json!({"trace_id": "zzz"}))
307 .parse()
308 .unwrap();
309 match outcome {
310 MutationOutcome::Error {
311 error_class,
312 message,
313 metadata,
314 } => {
315 assert_eq!(error_class, MutationErrorClass::Conflict);
316 assert_eq!(message, "duplicate");
317 assert_eq!(metadata, json!({"field": "email"}));
319 },
320 MutationOutcome::Success { .. } => panic!("expected Error"),
321 }
322 }
323
324 #[test]
325 fn semantics_illegal_partial_failure_rejected() {
326 let err = Row::new(false, true)
327 .with("error_class", json!("internal"))
328 .parse()
329 .expect_err("partial failure must be rejected");
330 match err {
331 FraiseQLError::Validation { message, .. } => {
332 assert!(message.contains("state_changed=true is illegal"), "got: {message}");
333 },
334 other => panic!("expected Validation error, got {other:?}"),
335 }
336 }
337
338 #[test]
339 fn error_requires_error_class() {
340 let err = Row::new(false, false)
341 .parse()
342 .expect_err("error row without error_class must be rejected");
343 assert!(matches!(err, FraiseQLError::Validation { .. }));
344 }
345
346 #[test]
347 fn success_rejects_error_class() {
348 let err = Row::new(true, true)
349 .with("error_class", json!("validation"))
350 .parse()
351 .expect_err("succeeded=true with error_class must be rejected");
352 assert!(matches!(err, FraiseQLError::Validation { .. }));
353 }
354
355 #[test]
356 fn http_status_range_enforced() {
357 let err = Row::new(true, false)
358 .with("http_status", json!(42))
359 .parse()
360 .expect_err("http_status out of range must be rejected");
361 match err {
362 FraiseQLError::Validation { message, .. } => {
363 assert!(message.contains("http_status"), "got: {message}");
364 },
365 other => panic!("expected Validation error, got {other:?}"),
366 }
367 }
368
369 #[test]
370 fn http_status_boundaries_accepted() {
371 for code in [100_i16, 200, 422, 599] {
372 Row::new(true, false)
373 .with("http_status", json!(code))
374 .parse()
375 .unwrap_or_else(|e| panic!("code {code} should be accepted: {e:?}"));
376 }
377 }
378
379 #[test]
380 fn as_str_round_trips_all_error_classes() {
381 let cases = [
382 (MutationErrorClass::Validation, "validation"),
383 (MutationErrorClass::Conflict, "conflict"),
384 (MutationErrorClass::NotFound, "not_found"),
385 (MutationErrorClass::Unauthorized, "unauthorized"),
386 (MutationErrorClass::Forbidden, "forbidden"),
387 (MutationErrorClass::Internal, "internal"),
388 (MutationErrorClass::TransactionFailed, "transaction_failed"),
389 (MutationErrorClass::Timeout, "timeout"),
390 (MutationErrorClass::RateLimited, "rate_limited"),
391 (MutationErrorClass::ServiceUnavailable, "service_unavailable"),
392 ];
393 for (class, expected) in cases {
394 assert_eq!(class.as_str(), expected, "class = {class:?}");
395 }
396 }
397
398 #[test]
399 fn entity_id_uuid_serialized_back_to_canonical_string() {
400 let eid = "550e8400-e29b-41d4-a716-446655440000";
401 let outcome = Row::new(true, true)
402 .with("entity_id", json!(eid))
403 .with("entity", json!({"id": eid}))
404 .parse()
405 .unwrap();
406 match outcome {
407 MutationOutcome::Success { entity_id, .. } => {
408 assert_eq!(entity_id.as_deref(), Some(eid));
409 },
410 MutationOutcome::Error { .. } => panic!("expected Success"),
411 }
412 }
413
414 #[test]
415 fn extra_columns_ignored() {
416 let outcome = Row::new(true, true)
419 .with("entity", json!({"id": "1"}))
420 .with("schema_version", json!(2))
421 .with("some_future_column", json!("whatever"))
422 .parse()
423 .unwrap();
424 assert!(matches!(outcome, MutationOutcome::Success { .. }));
425 }
426}