1use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17use crate::field::FieldMeaning;
18use crate::state::Transition;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27pub struct TransitionPlan {
28 pub action: String,
30 pub event: String,
32 pub from_states: Vec<String>,
34 pub to_state: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub guard: Option<String>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub effects: Vec<String>,
42}
43
44pub fn derive_transition_plan(
61 svc: &crate::ServiceDef,
62 action_name: &str,
63) -> Result<TransitionPlan, crate::Error> {
64 let action = svc
65 .actions
66 .iter()
67 .find(|a| a.name == action_name)
68 .ok_or_else(|| crate::Error::Validation(format!("no action '{action_name}'")))?;
69
70 let event = action
71 .transition_trigger
72 .as_deref()
73 .ok_or_else(|| crate::Error::NoTransitionTrigger(action_name.to_string()))?;
74
75 let sm = svc
76 .state_machine
77 .as_ref()
78 .ok_or_else(|| crate::Error::NoStateMachine(svc.name.clone()))?;
79
80 let matches: Vec<&Transition> = sm.states_for_event(event);
81 if matches.is_empty() {
82 return Err(crate::Error::UndeclaredTransition {
83 action: action_name.to_string(),
84 event: event.to_string(),
85 });
86 }
87
88 let to_state = matches[0].to.clone();
89 if matches.iter().any(|t| t.to != to_state) {
90 return Err(crate::Error::AmbiguousTransition {
91 event: event.to_string(),
92 });
93 }
94
95 let from_states: Vec<String> = matches.iter().map(|t| t.from.clone()).collect();
96
97 let guard = matches[0].guard.clone();
101
102 let mut effects: Vec<String> = Vec::new();
105 for e in matches[0].actions.iter().chain(action.effects.iter()) {
106 if !effects.iter().any(|existing| existing == e) {
107 effects.push(e.clone());
108 }
109 }
110
111 Ok(TransitionPlan {
112 action: action_name.to_string(),
113 event: event.to_string(),
114 from_states,
115 to_state,
116 guard,
117 effects,
118 })
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123#[serde(rename_all = "snake_case")]
124pub enum CrudVerb {
125 Create,
126 Update,
127 Delete,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134pub struct TenantColumn {
135 pub column: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
157pub enum CrudPlan {
158 Create {
162 table: String,
163 columns: Vec<(String, serde_json::Value)>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 tenant_column: Option<TenantColumn>,
169 },
170 Update {
173 table: String,
174 id_column: String,
175 id_value: serde_json::Value,
176 patch: Vec<(String, serde_json::Value)>,
178 soft_delete_column: String,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 tenant_column: Option<TenantColumn>,
184 },
185 Delete {
187 table: String,
188 id_column: String,
189 id_value: serde_json::Value,
190 soft_delete_column: String,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 tenant_column: Option<TenantColumn>,
195 },
196}
197
198pub fn derive_crud_plan(
210 svc: &crate::ServiceDef,
211 verb: CrudVerb,
212 inputs: &serde_json::Value,
213) -> Result<CrudPlan, crate::Error> {
214 let table = svc.resolved_table();
215 let has_sm = svc.state_machine.is_some();
216
217 match verb {
218 CrudVerb::Create => {
219 if !svc.creatable {
220 return Err(crate::Error::VerbNotEnabled(format!("{}.create", svc.name)));
221 }
222 let mut columns: Vec<(String, serde_json::Value)> = svc
225 .fields
226 .iter()
227 .filter(|f| !svc.is_write_excluded_field(f, has_sm))
228 .filter_map(|f| inputs.get(&f.name).map(|v| (f.name.clone(), v.clone())))
229 .collect();
230 if let Some(sm) = svc.state_machine.as_ref() {
232 if let Some(status_field) = svc
233 .fields
234 .iter()
235 .find(|f| matches!(f.meaning, FieldMeaning::Status))
236 {
237 columns.push((
238 status_field.name.clone(),
239 serde_json::json!(sm.initial_state),
240 ));
241 }
242 }
243 Ok(CrudPlan::Create {
245 table,
246 columns,
247 tenant_column: svc.tenant_column.as_ref().map(|col| TenantColumn {
248 column: col.clone(),
249 }),
250 })
251 }
252 CrudVerb::Update => {
253 if !svc.updatable {
254 return Err(crate::Error::VerbNotEnabled(format!("{}.update", svc.name)));
255 }
256 let id_value = inputs
257 .get("id")
258 .filter(|v| !v.is_null())
259 .cloned()
260 .ok_or_else(|| crate::Error::Validation("update requires an 'id' field".into()))?;
261 let patch = svc
263 .fields
264 .iter()
265 .filter(|f| !svc.is_write_excluded_field(f, has_sm))
266 .filter_map(|f| inputs.get(&f.name).map(|v| (f.name.clone(), v.clone())))
267 .collect();
268 Ok(CrudPlan::Update {
269 table,
270 id_column: "id".into(),
271 id_value,
272 patch,
273 soft_delete_column: svc.resolved_soft_delete_column().to_string(),
274 tenant_column: svc.tenant_column.as_ref().map(|col| TenantColumn {
275 column: col.clone(),
276 }),
277 })
278 }
279 CrudVerb::Delete => {
280 if !svc.deletable {
281 return Err(crate::Error::VerbNotEnabled(format!("{}.delete", svc.name)));
282 }
283 let id_value = inputs
284 .get("id")
285 .filter(|v| !v.is_null())
286 .cloned()
287 .ok_or_else(|| crate::Error::Validation("delete requires an 'id' field".into()))?;
288 Ok(CrudPlan::Delete {
289 table,
290 id_column: "id".into(),
291 id_value,
292 soft_delete_column: svc.resolved_soft_delete_column().to_string(),
293 tenant_column: svc.tenant_column.as_ref().map(|col| TenantColumn {
294 column: col.clone(),
295 }),
296 })
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::action::ActionDef;
305 use crate::state::{StateMachine, Transition};
306 use crate::ServiceDef;
307
308 fn order_service() -> ServiceDef {
312 let machine = StateMachine::new("order_lifecycle")
313 .initial("draft")
314 .transition(Transition::new("draft", "submit", "submitted").actions(vec!["log_submit"]))
315 .transition(Transition::new("submitted", "approve", "approved").guard("is_manager"))
316 .transition(Transition::new("submitted", "reject", "cancelled"))
317 .transition(Transition::new("approved", "ship", "shipped"))
318 .transition(Transition::new("shipped", "deliver", "delivered"))
319 .transition(Transition::new("draft", "cancel", "cancelled"))
320 .transition(Transition::new("submitted", "cancel", "cancelled"));
321
322 ServiceDef::new("order")
323 .state_machine(machine)
324 .action(
325 ActionDef::new("submit")
326 .transition_trigger("submit")
327 .effect("notify"),
328 )
329 .action(
330 ActionDef::new("approve")
331 .transition_trigger("approve")
332 .precondition("is_manager"),
333 )
334 .action(ActionDef::new("ship").transition_trigger("ship"))
335 .action(ActionDef::new("cancel").transition_trigger("cancel"))
336 }
337
338 #[test]
339 fn derive_transition_plan() {
340 let svc = order_service();
341 let plan = super::derive_transition_plan(&svc, "submit").unwrap();
342 assert_eq!(plan.action, "submit");
343 assert_eq!(plan.event, "submit");
344 assert_eq!(plan.to_state, "submitted");
345 assert_eq!(plan.from_states, vec!["draft".to_string()]);
346 assert_eq!(plan.guard, None);
347 assert_eq!(
349 plan.effects,
350 vec!["log_submit".to_string(), "notify".to_string()]
351 );
352 }
353
354 #[test]
355 fn derive_approve_carries_transition_guard() {
356 let svc = order_service();
357 let plan = super::derive_transition_plan(&svc, "approve").unwrap();
358 assert_eq!(plan.to_state, "approved");
359 assert_eq!(plan.guard, Some("is_manager".to_string()));
360 }
361
362 #[test]
363 fn derive_multi_source_event() {
364 let svc = order_service();
365 let plan = super::derive_transition_plan(&svc, "cancel").unwrap();
366 assert_eq!(plan.to_state, "cancelled");
367 assert!(plan.from_states.contains(&"draft".to_string()));
368 assert!(plan.from_states.contains(&"submitted".to_string()));
369 assert_eq!(plan.from_states.len(), 2);
370 }
371
372 #[test]
373 fn derive_no_trigger() {
374 let svc = ServiceDef::new("order")
375 .state_machine(StateMachine::new("sm").initial("draft"))
376 .action(ActionDef::new("noop"));
377 let err = super::derive_transition_plan(&svc, "noop").unwrap_err();
378 assert!(matches!(err, crate::Error::NoTransitionTrigger(a) if a == "noop"));
379 }
380
381 #[test]
382 fn derive_undeclared_trigger_errors() {
383 let svc = ServiceDef::new("order")
384 .state_machine(
385 StateMachine::new("sm")
386 .initial("draft")
387 .transition(Transition::new("draft", "submit", "submitted")),
388 )
389 .action(ActionDef::new("bogus").transition_trigger("does_not_exist"));
390 let err = super::derive_transition_plan(&svc, "bogus").unwrap_err();
391 assert!(matches!(
392 err,
393 crate::Error::UndeclaredTransition { ref action, ref event }
394 if action == "bogus" && event == "does_not_exist"
395 ));
396 }
397
398 #[test]
399 fn derive_no_state_machine() {
400 let svc =
401 ServiceDef::new("order").action(ActionDef::new("submit").transition_trigger("submit"));
402 let err = super::derive_transition_plan(&svc, "submit").unwrap_err();
403 assert!(matches!(err, crate::Error::NoStateMachine(name) if name == "order"));
404 }
405
406 #[test]
407 fn derive_no_action() {
408 let svc = order_service();
409 let err = super::derive_transition_plan(&svc, "ghost").unwrap_err();
410 assert!(matches!(err, crate::Error::Validation(_)));
411 }
412
413 #[test]
414 fn derive_ambiguous_fan_out() {
415 let svc = ServiceDef::new("order")
417 .state_machine(
418 StateMachine::new("sm")
419 .initial("a")
420 .transition(Transition::new("a", "split", "b"))
421 .transition(Transition::new("c", "split", "d")),
422 )
423 .action(ActionDef::new("split").transition_trigger("split"));
424 let err = super::derive_transition_plan(&svc, "split").unwrap_err();
425 assert!(matches!(err, crate::Error::AmbiguousTransition { event } if event == "split"));
426 }
427
428 #[test]
429 fn transition_plan_serde_round_trip() {
430 let svc = order_service();
431 let plan = super::derive_transition_plan(&svc, "submit").unwrap();
432 let json = serde_json::to_string(&plan).unwrap();
433 let back: TransitionPlan = serde_json::from_str(&json).unwrap();
434 assert_eq!(plan, back);
435 }
436
437 use crate::field::{DataType, FieldMeaning};
440
441 fn crud_order_service() -> ServiceDef {
444 let machine = StateMachine::new("order_lifecycle")
445 .initial("draft")
446 .transition(Transition::new("draft", "submit", "submitted"));
447
448 ServiceDef::new("order")
449 .mcp_write_ability("manage-orders")
450 .creatable(true)
451 .updatable(true)
452 .deletable(true)
453 .state_machine(machine)
454 .field("id", DataType::Integer, FieldMeaning::Identifier)
455 .field("status", DataType::String, FieldMeaning::Status)
456 .field("amount", DataType::String, FieldMeaning::Money)
457 .field("note", DataType::String, FieldMeaning::FreeText)
458 .field("created_at", DataType::String, FieldMeaning::CreatedAt)
459 }
460
461 fn crud_order_service_no_sm() -> ServiceDef {
463 ServiceDef::new("order")
464 .mcp_write_ability("manage-orders")
465 .creatable(true)
466 .updatable(true)
467 .deletable(true)
468 .field("id", DataType::Integer, FieldMeaning::Identifier)
469 .field("status", DataType::String, FieldMeaning::Status)
470 .field("amount", DataType::String, FieldMeaning::Money)
471 .field("note", DataType::String, FieldMeaning::FreeText)
472 .field("created_at", DataType::String, FieldMeaning::CreatedAt)
473 }
474
475 #[test]
476 fn derive_crud_plan_create() {
477 let svc = crud_order_service();
478 let inputs = serde_json::json!({ "amount": "100.00", "note": "rush order" });
479 let plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
480
481 let CrudPlan::Create {
482 ref table,
483 ref columns,
484 ref tenant_column,
485 } = plan
486 else {
487 panic!("expected CrudPlan::Create, got {plan:?}");
488 };
489
490 assert_eq!(table, "orders"); assert!(
494 columns
495 .iter()
496 .any(|(col, val)| col == "amount" && val == &serde_json::json!("100.00")),
497 "expected amount in columns"
498 );
499 assert!(
500 columns
501 .iter()
502 .any(|(col, val)| col == "note" && val == &serde_json::json!("rush order")),
503 "expected note in columns"
504 );
505
506 assert!(
508 columns
509 .iter()
510 .any(|(col, val)| col == "status" && val == &serde_json::json!("draft")),
511 "expected status=draft in columns when SM exists"
512 );
513
514 assert!(
516 !columns.iter().any(|(col, _)| col == "id"),
517 "id must not be in columns"
518 );
519 assert!(
520 !columns.iter().any(|(col, _)| col == "created_at"),
521 "created_at must not be in columns (executor injects it)"
522 );
523
524 assert_eq!(tenant_column, &None);
526 }
527
528 #[test]
529 fn derive_crud_plan_create_no_sm_status_included() {
530 let svc = crud_order_service_no_sm();
532 let inputs = serde_json::json!({ "amount": "50.00", "status": "active", "note": "test" });
533 let plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
534
535 let CrudPlan::Create { ref columns, .. } = plan else {
536 panic!("expected CrudPlan::Create");
537 };
538
539 assert!(
541 columns
542 .iter()
543 .any(|(col, val)| col == "status" && val == &serde_json::json!("active")),
544 "status must be in columns when no SM"
545 );
546 }
547
548 #[test]
549 fn derive_crud_plan_update() {
550 let svc = crud_order_service();
551 let inputs = serde_json::json!({ "id": 42, "amount": "200.00" });
552 let plan = super::derive_crud_plan(&svc, CrudVerb::Update, &inputs).unwrap();
553
554 let CrudPlan::Update {
555 ref table,
556 ref id_column,
557 ref id_value,
558 ref patch,
559 ref soft_delete_column,
560 ref tenant_column,
561 } = plan
562 else {
563 panic!("expected CrudPlan::Update, got {plan:?}");
564 };
565
566 assert_eq!(table, "orders");
567 assert_eq!(id_column, "id");
568 assert_eq!(id_value, &serde_json::json!(42));
569
570 assert!(
572 patch
573 .iter()
574 .any(|(col, val)| col == "amount" && val == &serde_json::json!("200.00")),
575 "expected amount in patch"
576 );
577 assert!(
579 !patch.iter().any(|(col, _)| col == "note"),
580 "note must not be in patch (not supplied)"
581 );
582 assert!(
584 !patch.iter().any(|(col, _)| col == "status"),
585 "status must not be in patch when SM exists"
586 );
587 assert!(
589 !patch.iter().any(|(col, _)| col == "id"),
590 "id must not be in patch"
591 );
592
593 assert_eq!(soft_delete_column, "deleted_at");
595
596 assert_eq!(tenant_column, &None);
598 }
599
600 #[test]
601 fn derive_crud_plan_delete() {
602 let svc = crud_order_service();
603 let inputs = serde_json::json!({ "id": 7 });
604 let plan = super::derive_crud_plan(&svc, CrudVerb::Delete, &inputs).unwrap();
605
606 let CrudPlan::Delete {
607 ref table,
608 ref id_column,
609 ref id_value,
610 ref soft_delete_column,
611 ref tenant_column,
612 } = plan
613 else {
614 panic!("expected CrudPlan::Delete, got {plan:?}");
615 };
616
617 assert_eq!(table, "orders");
618 assert_eq!(id_column, "id");
619 assert_eq!(id_value, &serde_json::json!(7));
620 assert_eq!(soft_delete_column, "deleted_at");
621 assert_eq!(tenant_column, &None);
622 }
623
624 #[test]
625 fn derive_crud_plan_verb_not_enabled() {
626 let svc = ServiceDef::new("product")
628 .mcp_write_ability("manage-products")
629 .field("id", DataType::Integer, FieldMeaning::Identifier)
630 .field("name", DataType::String, FieldMeaning::EntityName);
631
632 let inputs = serde_json::json!({ "name": "Widget" });
633
634 let err_create = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap_err();
635 assert!(
636 matches!(err_create, crate::Error::VerbNotEnabled(ref s) if s.contains("product.create")),
637 "expected VerbNotEnabled for create, got {err_create:?}"
638 );
639
640 let err_update = super::derive_crud_plan(&svc, CrudVerb::Update, &inputs).unwrap_err();
641 assert!(
642 matches!(err_update, crate::Error::VerbNotEnabled(ref s) if s.contains("product.update")),
643 "expected VerbNotEnabled for update, got {err_update:?}"
644 );
645
646 let err_delete = super::derive_crud_plan(&svc, CrudVerb::Delete, &inputs).unwrap_err();
647 assert!(
648 matches!(err_delete, crate::Error::VerbNotEnabled(ref s) if s.contains("product.delete")),
649 "expected VerbNotEnabled for delete, got {err_delete:?}"
650 );
651 }
652
653 #[test]
654 fn crud_plan_serde_round_trip() {
655 let svc = crud_order_service();
656 let inputs = serde_json::json!({ "amount": "50.00" });
657
658 let create_plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
660 let json = serde_json::to_string(&create_plan).unwrap();
661 let back: CrudPlan = serde_json::from_str(&json).unwrap();
662 assert_eq!(create_plan, back);
663
664 let update_inputs = serde_json::json!({ "id": 1, "amount": "75.00" });
666 let update_plan = super::derive_crud_plan(&svc, CrudVerb::Update, &update_inputs).unwrap();
667 let json = serde_json::to_string(&update_plan).unwrap();
668 let back: CrudPlan = serde_json::from_str(&json).unwrap();
669 assert_eq!(update_plan, back);
670
671 let delete_inputs = serde_json::json!({ "id": 3 });
673 let delete_plan = super::derive_crud_plan(&svc, CrudVerb::Delete, &delete_inputs).unwrap();
674 let json = serde_json::to_string(&delete_plan).unwrap();
675 let back: CrudPlan = serde_json::from_str(&json).unwrap();
676 assert_eq!(delete_plan, back);
677 }
678
679 #[test]
681 fn derive_crud_plan_update_missing_id_is_validation_error() {
682 let svc = crud_order_service();
683 let err = super::derive_crud_plan(
685 &svc,
686 CrudVerb::Update,
687 &serde_json::json!({ "amount": "50.00" }),
688 )
689 .unwrap_err();
690 assert!(
691 matches!(err, crate::Error::Validation(_)),
692 "expected Validation error for missing id on update, got {err:?}"
693 );
694 let err_null = super::derive_crud_plan(
696 &svc,
697 CrudVerb::Update,
698 &serde_json::json!({ "id": null, "amount": "50.00" }),
699 )
700 .unwrap_err();
701 assert!(
702 matches!(err_null, crate::Error::Validation(_)),
703 "expected Validation error for null id on update, got {err_null:?}"
704 );
705 }
706
707 #[test]
708 fn derive_crud_plan_delete_missing_id_is_validation_error() {
709 let svc = crud_order_service();
710 let err =
712 super::derive_crud_plan(&svc, CrudVerb::Delete, &serde_json::json!({})).unwrap_err();
713 assert!(
714 matches!(err, crate::Error::Validation(_)),
715 "expected Validation error for missing id on delete, got {err:?}"
716 );
717 let err_null =
719 super::derive_crud_plan(&svc, CrudVerb::Delete, &serde_json::json!({ "id": null }))
720 .unwrap_err();
721 assert!(
722 matches!(err_null, crate::Error::Validation(_)),
723 "expected Validation error for null id on delete, got {err_null:?}"
724 );
725 }
726
727 fn crud_order_service_with_tenant() -> ServiceDef {
731 let machine = StateMachine::new("order_lifecycle")
732 .initial("draft")
733 .transition(Transition::new("draft", "submit", "submitted"));
734
735 ServiceDef::new("order")
736 .mcp_write_ability("manage-orders")
737 .creatable(true)
738 .updatable(true)
739 .deletable(true)
740 .tenant_column("tenant_id")
741 .state_machine(machine)
742 .field("id", DataType::Integer, FieldMeaning::Identifier)
743 .field("status", DataType::String, FieldMeaning::Status)
744 .field("amount", DataType::String, FieldMeaning::Money)
745 .field("note", DataType::String, FieldMeaning::FreeText)
746 .field("created_at", DataType::String, FieldMeaning::CreatedAt)
747 }
748
749 #[test]
750 fn derive_crud_plan_create_tenant_column() {
751 let svc = crud_order_service_with_tenant();
753 let inputs = serde_json::json!({ "amount": "100.00" });
754 let plan = super::derive_crud_plan(&svc, CrudVerb::Create, &inputs).unwrap();
755
756 let CrudPlan::Create {
757 ref tenant_column, ..
758 } = plan
759 else {
760 panic!("expected CrudPlan::Create, got {plan:?}");
761 };
762 assert_eq!(
763 tenant_column,
764 &Some(TenantColumn {
765 column: "tenant_id".into()
766 }),
767 "create with tenant_column set must yield Some(TenantColumn)"
768 );
769
770 let svc_no_tenant = crud_order_service();
772 let plan_no = super::derive_crud_plan(&svc_no_tenant, CrudVerb::Create, &inputs).unwrap();
773 let CrudPlan::Create {
774 ref tenant_column, ..
775 } = plan_no
776 else {
777 panic!("expected CrudPlan::Create");
778 };
779 assert_eq!(
780 tenant_column, &None,
781 "create without tenant_column must yield None"
782 );
783 }
784
785 #[test]
786 fn derive_crud_plan_update_tenant_column() {
787 let svc = crud_order_service_with_tenant();
789 let inputs = serde_json::json!({ "id": 1, "amount": "200.00" });
790 let plan = super::derive_crud_plan(&svc, CrudVerb::Update, &inputs).unwrap();
791
792 let CrudPlan::Update {
793 ref tenant_column, ..
794 } = plan
795 else {
796 panic!("expected CrudPlan::Update, got {plan:?}");
797 };
798 assert_eq!(
799 tenant_column,
800 &Some(TenantColumn {
801 column: "tenant_id".into()
802 }),
803 "update with tenant_column set must yield Some(TenantColumn)"
804 );
805
806 let svc_no_tenant = crud_order_service();
808 let plan_no = super::derive_crud_plan(&svc_no_tenant, CrudVerb::Update, &inputs).unwrap();
809 let CrudPlan::Update {
810 ref tenant_column, ..
811 } = plan_no
812 else {
813 panic!("expected CrudPlan::Update");
814 };
815 assert_eq!(
816 tenant_column, &None,
817 "update without tenant_column must yield None"
818 );
819 }
820
821 #[test]
822 fn derive_crud_plan_delete_tenant_column() {
823 let svc = crud_order_service_with_tenant();
825 let inputs = serde_json::json!({ "id": 7 });
826 let plan = super::derive_crud_plan(&svc, CrudVerb::Delete, &inputs).unwrap();
827
828 let CrudPlan::Delete {
829 ref tenant_column, ..
830 } = plan
831 else {
832 panic!("expected CrudPlan::Delete, got {plan:?}");
833 };
834 assert_eq!(
835 tenant_column,
836 &Some(TenantColumn {
837 column: "tenant_id".into()
838 }),
839 "delete with tenant_column set must yield Some(TenantColumn)"
840 );
841
842 let svc_no_tenant = crud_order_service();
844 let plan_no = super::derive_crud_plan(&svc_no_tenant, CrudVerb::Delete, &inputs).unwrap();
845 let CrudPlan::Delete {
846 ref tenant_column, ..
847 } = plan_no
848 else {
849 panic!("expected CrudPlan::Delete");
850 };
851 assert_eq!(
852 tenant_column, &None,
853 "delete without tenant_column must yield None"
854 );
855 }
856}