1use crate::state::StateStore;
4use crate::types::{FieldChange, ObservedState, Op, Plan};
5use alembic_core::{
6 key_string, uid_v5, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
7};
8use serde_json::Value;
9use std::cmp::Reverse;
10use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
11
12pub fn plan(
14 desired: &[Object],
15 observed: &ObservedState,
16 state: &StateStore,
17 schema: &alembic_core::Schema,
18 allow_delete: bool,
19) -> Plan {
20 let mut ops = Vec::new();
21 let mut matched = BTreeSet::new();
22 let mut backend_to_uid = BTreeMap::new();
23
24 for (type_name, mapping) in state.all_mappings() {
25 for (uid, backend_id) in mapping {
26 backend_to_uid.insert((type_name.clone(), backend_id.clone()), *uid);
27 }
28 }
29
30 let mut desired_sorted: Vec<&Object> = desired.iter().collect();
31 desired_sorted.sort_by_key(|a| op_sort_key(&a.type_name, &a.key));
32
33 for object in desired_sorted {
34 let observed_object = state
35 .backend_id(object.type_name.clone(), object.uid)
36 .and_then(|id| observed.by_backend_id.get(&(object.type_name.clone(), id)))
37 .or_else(|| {
38 observed
39 .by_key
40 .get(&(object.type_name.clone(), key_string(&object.key)))
41 });
42
43 if let Some(obs) = observed_object {
44 let changes = diff_object(obs, object);
45 if !changes.is_empty() {
46 ops.push(Op::Update {
47 uid: object.uid,
48 type_name: object.type_name.clone(),
49 desired: object.clone(),
50 changes,
51 backend_id: obs.backend_id.clone(),
52 });
53 }
54 if let Some(backend_id) = &obs.backend_id {
55 matched.insert((object.type_name.clone(), backend_id.clone()));
56 }
57 } else {
58 ops.push(Op::Create {
59 uid: object.uid,
60 type_name: object.type_name.clone(),
61 desired: object.clone(),
62 });
63 }
64 }
65
66 if allow_delete {
67 for ((type_name, backend_id), obs) in &observed.by_backend_id {
68 if matched.contains(&(type_name.clone(), backend_id.clone())) {
69 continue;
70 }
71 let uid = backend_to_uid
72 .get(&(type_name.clone(), backend_id.clone()))
73 .copied()
74 .unwrap_or_else(|| uid_v5(type_name.as_str(), &key_string(&obs.key)));
75 ops.push(Op::Delete {
76 uid,
77 type_name: type_name.clone(),
78 key: obs.key.clone(),
79 backend_id: Some(backend_id.clone()),
80 });
81 }
82 }
83
84 ops.sort_by_key(op_order_key);
85
86 let mut plan = Plan {
87 schema: schema.clone(),
88 ops,
89 summary: None,
90 schema_preview: None,
91 };
92 plan.summary = Some(plan.summary());
93 plan
94}
95
96fn diff_attrs(existing: &JsonMap, desired: &JsonMap) -> Vec<FieldChange> {
98 let mut changes = Vec::new();
99 for (field, to) in desired.iter() {
100 let from = existing.get(field).cloned().unwrap_or(Value::Null);
101 if from != *to {
102 changes.push(FieldChange {
103 field: field.clone(),
104 from,
105 to: to.clone(),
106 });
107 }
108 }
109 changes
110}
111
112fn diff_object(existing: &crate::types::ObservedObject, desired: &Object) -> Vec<FieldChange> {
113 diff_attrs(&existing.attrs, &desired.attrs)
114}
115
116fn op_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
118 (type_name.as_str().to_string(), key_string(key))
119}
120
121type OrderKey = (String, u8, String);
123
124fn op_order_key(op: &Op) -> OrderKey {
126 let (type_name, key, weight) = match op {
127 Op::Create {
128 type_name, desired, ..
129 } => (type_name.clone(), key_string(&desired.key), 0u8),
130 Op::Update {
131 type_name, desired, ..
132 } => (type_name.clone(), key_string(&desired.key), 1u8),
133 Op::Delete { type_name, key, .. } => (type_name.clone(), key_string(key), 2u8),
134 };
135 (type_name.as_str().to_string(), weight, key)
136}
137
138fn collect_refs_in_value(field_type: &FieldType, value: &Value, out: &mut BTreeSet<Uid>) {
141 match field_type {
142 FieldType::Ref { .. } => {
143 if let Some(uid) = value.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
144 out.insert(uid);
145 }
146 }
147 FieldType::ListRef { .. } => {
148 if let Value::Array(items) = value {
149 for item in items {
150 if let Some(uid) = item.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
151 out.insert(uid);
152 }
153 }
154 }
155 }
156 FieldType::List { item } => {
157 if let Value::Array(items) = value {
158 for item_value in items {
159 collect_refs_in_value(item, item_value, out);
160 }
161 }
162 }
163 FieldType::Map { value: inner } => {
164 if let Value::Object(map) = value {
165 for entry in map.values() {
166 collect_refs_in_value(inner, entry, out);
167 }
168 }
169 }
170 _ => {}
171 }
172}
173
174fn collect_referenced_uids(
178 type_schema: &TypeSchema,
179 attrs: &BTreeMap<String, Value>,
180) -> BTreeSet<Uid> {
181 let mut uids = BTreeSet::new();
182 for (field, value) in attrs {
183 if let Some(field_schema) = type_schema
184 .fields
185 .get(field)
186 .or_else(|| type_schema.key.get(field))
187 {
188 collect_refs_in_value(&field_schema.r#type, value, &mut uids);
189 }
190 }
191 uids
192}
193
194fn op_referenced_uids(op: &Op, schema: &Schema) -> BTreeSet<Uid> {
198 let Some(type_schema) = schema.types.get(op.type_name().as_str()) else {
199 return BTreeSet::new();
200 };
201 match op {
202 Op::Create { desired, .. } | Op::Update { desired, .. } => {
203 let mut uids = collect_referenced_uids(type_schema, &desired.attrs);
204 uids.extend(collect_referenced_uids(type_schema, &desired.key));
205 uids
206 }
207 Op::Delete { key, .. } => collect_referenced_uids(type_schema, key),
208 }
209}
210
211fn stable_toposort(ops: &[&Op], edges: &[BTreeSet<usize>]) -> Vec<usize> {
217 let n = ops.len();
218 let keys: Vec<OrderKey> = ops.iter().map(|&op| op_order_key(op)).collect();
219
220 let mut indegree = vec![0usize; n];
221 for succs in edges {
222 for &b in succs {
223 indegree[b] += 1;
224 }
225 }
226
227 let mut ready: BinaryHeap<Reverse<(OrderKey, usize)>> = BinaryHeap::new();
228 for (i, °) in indegree.iter().enumerate() {
229 if deg == 0 {
230 ready.push(Reverse((keys[i].clone(), i)));
231 }
232 }
233
234 let mut order = Vec::with_capacity(n);
235 while let Some(Reverse((_, i))) = ready.pop() {
236 order.push(i);
237 for &b in &edges[i] {
238 indegree[b] -= 1;
239 if indegree[b] == 0 {
240 ready.push(Reverse((keys[b].clone(), b)));
241 }
242 }
243 }
244
245 if order.len() < n {
248 let mut placed = vec![false; n];
249 for &i in &order {
250 placed[i] = true;
251 }
252 let mut remaining: Vec<usize> = (0..n).filter(|&i| !placed[i]).collect();
253 remaining.sort_by(|&a, &b| keys[a].cmp(&keys[b]).then(a.cmp(&b)));
254 order.extend(remaining);
255 }
256
257 order
258}
259
260fn ordered_by_refs(ops: &[&Op], schema: &Schema, reverse: bool) -> Vec<Op> {
265 let uid_to_node: BTreeMap<Uid, usize> = ops
266 .iter()
267 .enumerate()
268 .map(|(i, op)| (op.uid(), i))
269 .collect();
270
271 let mut edges: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); ops.len()];
272 for (i, &op) in ops.iter().enumerate() {
273 for referenced in op_referenced_uids(op, schema) {
274 let Some(&j) = uid_to_node.get(&referenced) else {
275 continue;
276 };
277 if i == j {
278 continue;
279 }
280 if reverse {
281 edges[i].insert(j); } else {
283 edges[j].insert(i); }
285 }
286 }
287
288 stable_toposort(ops, &edges)
289 .into_iter()
290 .map(|i| ops[i].clone())
291 .collect()
292}
293
294pub fn sort_ops_for_apply(ops: &[Op], schema: &Schema) -> Vec<Op> {
299 let (non_deletes, deletes): (Vec<&Op>, Vec<&Op>) =
300 ops.iter().partition(|op| !matches!(op, Op::Delete { .. }));
301
302 let mut result = ordered_by_refs(&non_deletes, schema, false);
303 result.extend(ordered_by_refs(&deletes, schema, true));
304 result
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::state::{StateData, StateStore};
311 use crate::types::{BackendId, ObservedObject, ObservedState};
312 use alembic_core::{
313 FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
314 };
315 use serde_json::json;
316 use std::collections::BTreeMap;
317
318 fn make_key(slug: &str) -> Key {
319 let mut k = BTreeMap::new();
320 k.insert("slug".to_string(), json!(slug));
321 Key::from(k)
322 }
323
324 fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
325 let mut m = BTreeMap::new();
326 for (k, v) in pairs {
327 m.insert(k.to_string(), v.clone());
328 }
329 JsonMap::from(m)
330 }
331
332 fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
333 Object::new(
334 Uid::from_u128(uid),
335 TypeName::new(type_name),
336 make_key(slug),
337 attrs,
338 )
339 .unwrap()
340 }
341
342 fn empty_schema() -> Schema {
343 Schema {
344 types: BTreeMap::new(),
345 }
346 }
347
348 fn empty_state() -> StateStore {
349 StateStore::new(None, StateData::default())
350 }
351
352 #[test]
355 fn diff_attrs_identical_maps() {
356 let attrs = make_attrs(&[("name", json!("FRA1"))]);
357 let changes = diff_attrs(&attrs, &attrs);
358 assert!(changes.is_empty());
359 }
360
361 #[test]
362 fn diff_attrs_field_changed() {
363 let existing = make_attrs(&[("name", json!("FRA1"))]);
364 let desired = make_attrs(&[("name", json!("FRA2"))]);
365 let changes = diff_attrs(&existing, &desired);
366 assert_eq!(changes.len(), 1);
367 assert_eq!(changes[0].field, "name");
368 assert_eq!(changes[0].from, json!("FRA1"));
369 assert_eq!(changes[0].to, json!("FRA2"));
370 }
371
372 #[test]
373 fn diff_attrs_field_added() {
374 let existing = make_attrs(&[]);
375 let desired = make_attrs(&[("name", json!("FRA1"))]);
376 let changes = diff_attrs(&existing, &desired);
377 assert_eq!(changes.len(), 1);
378 assert_eq!(changes[0].field, "name");
379 assert_eq!(changes[0].from, json!(null));
380 assert_eq!(changes[0].to, json!("FRA1"));
381 }
382
383 #[test]
384 fn diff_attrs_field_removed_in_desired_is_ignored() {
385 let existing = make_attrs(&[("name", json!("FRA1")), ("extra", json!(true))]);
386 let desired = make_attrs(&[("name", json!("FRA1"))]);
387 let changes = diff_attrs(&existing, &desired);
388 assert!(changes.is_empty());
389 }
390
391 #[test]
392 fn diff_attrs_multiple_changes() {
393 let existing = make_attrs(&[("a", json!(1)), ("b", json!(2))]);
394 let desired = make_attrs(&[("a", json!(10)), ("b", json!(20))]);
395 let changes = diff_attrs(&existing, &desired);
396 assert_eq!(changes.len(), 2);
397 let fields: Vec<&str> = changes.iter().map(|c| c.field.as_str()).collect();
398 assert!(fields.contains(&"a"));
399 assert!(fields.contains(&"b"));
400 }
401
402 #[test]
405 fn plan_creates_for_new_objects() {
406 let desired = vec![make_object(
407 1,
408 "dcim.site",
409 "fra1",
410 make_attrs(&[("name", json!("FRA1"))]),
411 )];
412 let observed = ObservedState::default();
413 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
414 assert_eq!(result.ops.len(), 1);
415 assert!(matches!(&result.ops[0], Op::Create { uid, type_name, .. }
416 if *uid == Uid::from_u128(1) && type_name.as_str() == "dcim.site"));
417 let summary = result.summary.unwrap();
418 assert_eq!(summary.create, 1);
419 assert_eq!(summary.update, 0);
420 assert_eq!(summary.delete, 0);
421 }
422
423 #[test]
424 fn plan_updates_when_attrs_differ() {
425 let desired = vec![make_object(
426 1,
427 "dcim.site",
428 "fra1",
429 make_attrs(&[("name", json!("FRA2"))]),
430 )];
431 let mut observed = ObservedState::default();
432 observed
433 .insert(ObservedObject {
434 type_name: TypeName::new("dcim.site"),
435 key: make_key("fra1"),
436 attrs: make_attrs(&[("name", json!("FRA1"))]),
437 backend_id: Some(BackendId::Int(100)),
438 })
439 .unwrap();
440 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
441 assert_eq!(result.ops.len(), 1);
442 match &result.ops[0] {
443 Op::Update {
444 changes,
445 backend_id,
446 ..
447 } => {
448 assert_eq!(changes.len(), 1);
449 assert_eq!(changes[0].field, "name");
450 assert_eq!(backend_id, &Some(BackendId::Int(100)));
451 }
452 other => panic!("expected Update, got {:?}", other),
453 }
454 }
455
456 #[test]
457 fn plan_no_op_when_identical() {
458 let desired = vec![make_object(
459 1,
460 "dcim.site",
461 "fra1",
462 make_attrs(&[("name", json!("FRA1"))]),
463 )];
464 let mut observed = ObservedState::default();
465 observed
466 .insert(ObservedObject {
467 type_name: TypeName::new("dcim.site"),
468 key: make_key("fra1"),
469 attrs: make_attrs(&[("name", json!("FRA1"))]),
470 backend_id: Some(BackendId::Int(100)),
471 })
472 .unwrap();
473 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
474 assert!(result.ops.is_empty());
475 }
476
477 #[test]
478 fn plan_deletes_unmatched_when_allowed() {
479 let desired = vec![];
480 let mut observed = ObservedState::default();
481 observed
482 .insert(ObservedObject {
483 type_name: TypeName::new("dcim.site"),
484 key: make_key("fra1"),
485 attrs: make_attrs(&[("name", json!("FRA1"))]),
486 backend_id: Some(BackendId::Int(100)),
487 })
488 .unwrap();
489 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
490 assert_eq!(result.ops.len(), 1);
491 assert!(matches!(
492 &result.ops[0],
493 Op::Delete {
494 backend_id: Some(BackendId::Int(100)),
495 ..
496 }
497 ));
498 }
499
500 #[test]
501 fn plan_no_deletes_when_disallowed() {
502 let desired = vec![];
503 let mut observed = ObservedState::default();
504 observed
505 .insert(ObservedObject {
506 type_name: TypeName::new("dcim.site"),
507 key: make_key("fra1"),
508 attrs: make_attrs(&[("name", json!("FRA1"))]),
509 backend_id: Some(BackendId::Int(100)),
510 })
511 .unwrap();
512 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
513 assert!(result.ops.is_empty());
514 }
515
516 #[test]
517 fn plan_matched_objects_not_deleted() {
518 let desired = vec![make_object(
519 1,
520 "dcim.site",
521 "fra1",
522 make_attrs(&[("name", json!("FRA1"))]),
523 )];
524 let mut observed = ObservedState::default();
525 observed
526 .insert(ObservedObject {
527 type_name: TypeName::new("dcim.site"),
528 key: make_key("fra1"),
529 attrs: make_attrs(&[("name", json!("FRA1"))]),
530 backend_id: Some(BackendId::Int(100)),
531 })
532 .unwrap();
533 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
534 assert!(result.ops.is_empty());
535 }
536
537 #[test]
538 fn plan_deletes_cross_type_id_collision() {
539 let desired = vec![make_object(
542 1,
543 "dcim.site",
544 "fra1",
545 make_attrs(&[("name", json!("FRA1"))]),
546 )];
547 let mut observed = ObservedState::default();
548 observed
549 .insert(ObservedObject {
550 type_name: TypeName::new("dcim.site"),
551 key: make_key("fra1"),
552 attrs: make_attrs(&[("name", json!("FRA1"))]),
553 backend_id: Some(BackendId::Int(100)),
554 })
555 .unwrap();
556 observed
557 .insert(ObservedObject {
558 type_name: TypeName::new("dcim.device"),
559 key: make_key("leaf01"),
560 attrs: make_attrs(&[]),
561 backend_id: Some(BackendId::Int(100)),
562 })
563 .unwrap();
564 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
565 let deletes: Vec<&Op> = result
566 .ops
567 .iter()
568 .filter(|op| matches!(op, Op::Delete { .. }))
569 .collect();
570 assert_eq!(deletes.len(), 1);
571 assert!(matches!(
572 deletes[0],
573 Op::Delete { type_name, .. } if type_name.as_str() == "dcim.device"
574 ));
575 }
576
577 #[test]
578 fn plan_mixed_create_update_delete() {
579 let desired = vec![
580 make_object(
581 1,
582 "dcim.site",
583 "fra1",
584 make_attrs(&[("name", json!("FRA1-new"))]),
585 ),
586 make_object(
587 2,
588 "dcim.site",
589 "ams1",
590 make_attrs(&[("name", json!("AMS1"))]),
591 ),
592 ];
593 let mut observed = ObservedState::default();
594 observed
595 .insert(ObservedObject {
596 type_name: TypeName::new("dcim.site"),
597 key: make_key("fra1"),
598 attrs: make_attrs(&[("name", json!("FRA1"))]),
599 backend_id: Some(BackendId::Int(100)),
600 })
601 .unwrap();
602 observed
603 .insert(ObservedObject {
604 type_name: TypeName::new("dcim.site"),
605 key: make_key("lhr1"),
606 attrs: make_attrs(&[("name", json!("LHR1"))]),
607 backend_id: Some(BackendId::Int(200)),
608 })
609 .unwrap();
610 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
611
612 let creates: Vec<_> = result
613 .ops
614 .iter()
615 .filter(|op| matches!(op, Op::Create { .. }))
616 .collect();
617 let updates: Vec<_> = result
618 .ops
619 .iter()
620 .filter(|op| matches!(op, Op::Update { .. }))
621 .collect();
622 let deletes: Vec<_> = result
623 .ops
624 .iter()
625 .filter(|op| matches!(op, Op::Delete { .. }))
626 .collect();
627 assert_eq!(creates.len(), 1);
628 assert_eq!(updates.len(), 1);
629 assert_eq!(deletes.len(), 1);
630
631 let summary = result.summary.unwrap();
632 assert_eq!(summary.create, 1);
633 assert_eq!(summary.update, 1);
634 assert_eq!(summary.delete, 1);
635 }
636
637 #[test]
638 fn plan_uses_state_mapping_for_lookup() {
639 let mut state_data = StateData::default();
640 state_data
641 .mappings
642 .entry(TypeName::new("dcim.site"))
643 .or_default()
644 .insert(Uid::from_u128(1), BackendId::Int(100));
645 let state = StateStore::new(None, state_data);
646
647 let desired = vec![make_object(
648 1,
649 "dcim.site",
650 "fra1",
651 make_attrs(&[("name", json!("FRA2"))]),
652 )];
653 let mut observed = ObservedState::default();
654 observed
655 .insert(ObservedObject {
656 type_name: TypeName::new("dcim.site"),
657 key: make_key("fra1"),
658 attrs: make_attrs(&[("name", json!("FRA1"))]),
659 backend_id: Some(BackendId::Int(100)),
660 })
661 .unwrap();
662 let result = plan(&desired, &observed, &state, &empty_schema(), false);
663 assert_eq!(result.ops.len(), 1);
664 assert!(matches!(&result.ops[0], Op::Update { .. }));
665 }
666
667 #[test]
668 fn plan_matches_renamed_object_by_backend_id() {
669 let mut state_data = StateData::default();
670 state_data
671 .mappings
672 .entry(TypeName::new("dcim.site"))
673 .or_default()
674 .insert(Uid::from_u128(1), BackendId::Int(100));
675 let state = StateStore::new(None, state_data);
676
677 let desired = vec![make_object(
678 1,
679 "dcim.site",
680 "fra1-renamed",
681 make_attrs(&[("name", json!("FRA2"))]),
682 )];
683 let mut observed = ObservedState::default();
684 observed
685 .insert(ObservedObject {
686 type_name: TypeName::new("dcim.site"),
687 key: make_key("fra1-old"),
688 attrs: make_attrs(&[("name", json!("FRA1"))]),
689 backend_id: Some(BackendId::Int(100)),
690 })
691 .unwrap();
692 let result = plan(&desired, &observed, &state, &empty_schema(), true);
693 assert_eq!(result.ops.len(), 1);
694 assert!(matches!(
695 &result.ops[0],
696 Op::Update {
697 backend_id: Some(BackendId::Int(100)),
698 ..
699 }
700 ));
701 }
702
703 #[test]
704 fn plan_delete_uses_tracked_uid() {
705 let mut state_data = StateData::default();
706 state_data
707 .mappings
708 .entry(TypeName::new("dcim.site"))
709 .or_default()
710 .insert(Uid::from_u128(42), BackendId::Int(100));
711 let state = StateStore::new(None, state_data);
712
713 let mut observed = ObservedState::default();
714 observed
715 .insert(ObservedObject {
716 type_name: TypeName::new("dcim.site"),
717 key: make_key("fra1"),
718 attrs: make_attrs(&[]),
719 backend_id: Some(BackendId::Int(100)),
720 })
721 .unwrap();
722
723 let result = plan(&[], &observed, &state, &empty_schema(), true);
724 assert_eq!(result.ops.len(), 1);
725 match &result.ops[0] {
726 Op::Delete { uid, .. } => {
727 assert_eq!(*uid, Uid::from_u128(42));
728 assert_ne!(*uid, uid_v5("dcim.site", &key_string(&make_key("fra1"))));
729 }
730 other => panic!("expected delete, got {other:?}"),
731 }
732 }
733
734 #[test]
737 fn sort_ops_creates_before_deletes() {
738 let ops = vec![
739 Op::Delete {
740 uid: Uid::from_u128(1),
741 type_name: TypeName::new("dcim.site"),
742 key: make_key("fra1"),
743 backend_id: Some(BackendId::Int(100)),
744 },
745 Op::Create {
746 uid: Uid::from_u128(2),
747 type_name: TypeName::new("dcim.site"),
748 desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
749 },
750 ];
751 let sorted = sort_ops_for_apply(&ops, &empty_schema());
752 assert!(matches!(&sorted[0], Op::Create { .. }));
753 assert!(matches!(&sorted[1], Op::Delete { .. }));
754 }
755
756 #[test]
757 fn sort_ops_deletes_last() {
758 let ops = vec![
759 Op::Delete {
760 uid: Uid::from_u128(1),
761 type_name: TypeName::new("a.type"),
762 key: make_key("a"),
763 backend_id: None,
764 },
765 Op::Delete {
766 uid: Uid::from_u128(2),
767 type_name: TypeName::new("z.type"),
768 key: make_key("z"),
769 backend_id: None,
770 },
771 ];
772 let sorted = sort_ops_for_apply(&ops, &empty_schema());
773 assert!(
775 matches!(&sorted[0], Op::Delete { type_name, .. } if type_name.as_str() == "a.type")
776 );
777 assert!(
778 matches!(&sorted[1], Op::Delete { type_name, .. } if type_name.as_str() == "z.type")
779 );
780 }
781
782 #[test]
783 fn sort_ops_empty_input() {
784 let sorted = sort_ops_for_apply(&[], &empty_schema());
785 assert!(sorted.is_empty());
786 }
787
788 #[test]
789 fn sort_ops_preserves_create_update_order() {
790 let ops = vec![
791 Op::Update {
792 uid: Uid::from_u128(2),
793 type_name: TypeName::new("dcim.site"),
794 desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
795 changes: vec![],
796 backend_id: None,
797 },
798 Op::Create {
799 uid: Uid::from_u128(1),
800 type_name: TypeName::new("dcim.site"),
801 desired: make_object(1, "dcim.site", "aaa1", make_attrs(&[])),
802 },
803 ];
804 let sorted = sort_ops_for_apply(&ops, &empty_schema());
805 assert!(matches!(&sorted[0], Op::Create { .. }));
806 assert!(matches!(&sorted[1], Op::Update { .. }));
807 }
808
809 fn field(r#type: FieldType) -> FieldSchema {
812 FieldSchema {
813 r#type,
814 required: false,
815 nullable: true,
816 format: None,
817 pattern: None,
818 description: None,
819 }
820 }
821
822 fn ref_t() -> FieldType {
823 FieldType::Ref {
824 target: "node".to_string(),
825 }
826 }
827
828 fn list_ref_t() -> FieldType {
829 FieldType::ListRef {
830 target: "node".to_string(),
831 }
832 }
833
834 fn node_schema(fields: &[(&str, FieldType)]) -> Schema {
836 let mut field_map = BTreeMap::new();
837 for (name, ty) in fields {
838 field_map.insert(name.to_string(), field(ty.clone()));
839 }
840 let mut key = BTreeMap::new();
841 key.insert("slug".to_string(), field(FieldType::Slug));
842 let mut types = BTreeMap::new();
843 types.insert(
844 "node".to_string(),
845 TypeSchema {
846 key,
847 fields: field_map,
848 },
849 );
850 Schema { types }
851 }
852
853 fn uref(uid: u128) -> serde_json::Value {
855 json!(Uid::from_u128(uid).to_string())
856 }
857
858 fn create_node(uid: u128, slug: &str, attrs: JsonMap) -> Op {
859 Op::Create {
860 uid: Uid::from_u128(uid),
861 type_name: TypeName::new("node"),
862 desired: make_object(uid, "node", slug, attrs),
863 }
864 }
865
866 fn order_index(ops: &[Op], uid: u128) -> usize {
868 ops.iter()
869 .position(|op| op.uid().as_u128() == uid)
870 .expect("uid present in ordered ops")
871 }
872
873 #[test]
874 fn toposort_linear_ref_chain() {
875 let schema = node_schema(&[("next", ref_t())]);
876 let ops = vec![
877 create_node(1, "a", make_attrs(&[("next", uref(2))])),
878 create_node(2, "b", make_attrs(&[("next", uref(3))])),
879 create_node(3, "c", make_attrs(&[])),
880 ];
881 let sorted = sort_ops_for_apply(&ops, &schema);
882 assert!(order_index(&sorted, 3) < order_index(&sorted, 2));
884 assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
885 }
886
887 #[test]
888 fn toposort_diamond_shared_dep() {
889 let schema = node_schema(&[("parent", ref_t()), ("left", ref_t()), ("right", ref_t())]);
890 let ops = vec![
891 create_node(1, "d", make_attrs(&[("left", uref(2)), ("right", uref(3))])),
892 create_node(2, "b", make_attrs(&[("parent", uref(4))])),
893 create_node(3, "c", make_attrs(&[("parent", uref(4))])),
894 create_node(4, "a", make_attrs(&[])),
895 ];
896 let sorted = sort_ops_for_apply(&ops, &schema);
897 let (a, b, c, d) = (
898 order_index(&sorted, 4),
899 order_index(&sorted, 2),
900 order_index(&sorted, 3),
901 order_index(&sorted, 1),
902 );
903 assert!(a < b && a < c, "shared dep created first");
904 assert!(b < d && c < d, "both branches created before the joiner");
905 }
906
907 #[test]
908 fn toposort_list_ref_fan_out() {
909 let schema = node_schema(&[("members", list_ref_t())]);
910 let ops = vec![
911 create_node(
912 1,
913 "a",
914 make_attrs(&[("members", json!([uref(2), uref(3), uref(4)]))]),
915 ),
916 create_node(2, "b", make_attrs(&[])),
917 create_node(3, "c", make_attrs(&[])),
918 create_node(4, "d", make_attrs(&[])),
919 ];
920 let sorted = sort_ops_for_apply(&ops, &schema);
921 let a = order_index(&sorted, 1);
922 assert!(order_index(&sorted, 2) < a);
923 assert!(order_index(&sorted, 3) < a);
924 assert!(order_index(&sorted, 4) < a);
925 }
926
927 #[test]
928 fn toposort_refs_nested_in_list_and_map() {
929 let schema = node_schema(&[
930 (
931 "group",
932 FieldType::List {
933 item: Box::new(ref_t()),
934 },
935 ),
936 (
937 "lookup",
938 FieldType::Map {
939 value: Box::new(ref_t()),
940 },
941 ),
942 ]);
943 let ops = vec![
944 create_node(
945 1,
946 "a",
947 make_attrs(&[
948 ("group", json!([uref(2)])),
949 ("lookup", json!({ "k": uref(3) })),
950 ]),
951 ),
952 create_node(2, "b", make_attrs(&[])),
953 create_node(3, "c", make_attrs(&[])),
954 ];
955 let sorted = sort_ops_for_apply(&ops, &schema);
956 let a = order_index(&sorted, 1);
957 assert!(order_index(&sorted, 2) < a, "ref nested in list");
958 assert!(order_index(&sorted, 3) < a, "ref nested in map");
959 }
960
961 #[test]
962 fn toposort_reference_cycle_falls_back_to_stable_order() {
963 let schema = node_schema(&[("next", ref_t())]);
964 let ops = vec![
965 create_node(1, "aaa", make_attrs(&[("next", uref(2))])),
966 create_node(2, "bbb", make_attrs(&[("next", uref(1))])),
967 ];
968 let sorted = sort_ops_for_apply(&ops, &schema);
969 assert_eq!(sorted.len(), 2);
971 assert_eq!(order_index(&sorted, 1), 0);
973 assert_eq!(order_index(&sorted, 2), 1);
974 }
975
976 #[test]
977 fn reverse_toposort_deletes_child_before_parent() {
978 let mut node_key = BTreeMap::new();
981 node_key.insert("slug".to_string(), field(FieldType::Slug));
982 let mut child_key = BTreeMap::new();
983 child_key.insert("parent".to_string(), field(ref_t()));
984 child_key.insert("slug".to_string(), field(FieldType::Slug));
985 let mut types = BTreeMap::new();
986 types.insert(
987 "node".to_string(),
988 TypeSchema {
989 key: node_key,
990 fields: BTreeMap::new(),
991 },
992 );
993 types.insert(
994 "child".to_string(),
995 TypeSchema {
996 key: child_key,
997 fields: BTreeMap::new(),
998 },
999 );
1000 let schema = Schema { types };
1001
1002 let parent_key = Key::from(BTreeMap::from([("slug".to_string(), json!("p"))]));
1003 let child_key_val = Key::from(BTreeMap::from([
1004 ("parent".to_string(), uref(1)),
1005 ("slug".to_string(), json!("c")),
1006 ]));
1007 let ops = vec![
1008 Op::Delete {
1009 uid: Uid::from_u128(1),
1010 type_name: TypeName::new("node"),
1011 key: parent_key,
1012 backend_id: None,
1013 },
1014 Op::Delete {
1015 uid: Uid::from_u128(2),
1016 type_name: TypeName::new("child"),
1017 key: child_key_val,
1018 backend_id: None,
1019 },
1020 ];
1021 let sorted = sort_ops_for_apply(&ops, &schema);
1022 assert!(
1023 order_index(&sorted, 2) < order_index(&sorted, 1),
1024 "child deleted before the parent it references"
1025 );
1026 assert!(sorted.iter().all(|op| matches!(op, Op::Delete { .. })));
1027 }
1028
1029 #[test]
1030 fn toposort_keeps_deletes_after_creates_and_updates() {
1031 let schema = node_schema(&[("next", ref_t())]);
1032 let ops = vec![
1033 Op::Delete {
1034 uid: Uid::from_u128(9),
1035 type_name: TypeName::new("node"),
1036 key: make_key("z"),
1037 backend_id: None,
1038 },
1039 create_node(1, "a", make_attrs(&[("next", uref(2))])),
1040 create_node(2, "b", make_attrs(&[])),
1041 ];
1042 let sorted = sort_ops_for_apply(&ops, &schema);
1043 let non_delete_count = sorted
1044 .iter()
1045 .filter(|op| !matches!(op, Op::Delete { .. }))
1046 .count();
1047 let delete_pos = sorted
1048 .iter()
1049 .position(|op| matches!(op, Op::Delete { .. }))
1050 .unwrap();
1051 assert_eq!(delete_pos, non_delete_count);
1053 assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
1055 }
1056}