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 type_schema = schema.types.get(object.type_name.as_str());
45 let changes = diff_object(obs, object, type_schema);
46 if !changes.is_empty() {
47 ops.push(Op::Update {
48 uid: object.uid,
49 type_name: object.type_name.clone(),
50 desired: object.clone(),
51 changes,
52 backend_id: obs.backend_id.clone(),
53 });
54 }
55 if let Some(backend_id) = &obs.backend_id {
56 matched.insert((object.type_name.clone(), backend_id.clone()));
57 }
58 } else {
59 ops.push(Op::Create {
60 uid: object.uid,
61 type_name: object.type_name.clone(),
62 desired: object.clone(),
63 });
64 }
65 }
66
67 if allow_delete {
68 for ((type_name, backend_id), obs) in &observed.by_backend_id {
69 if matched.contains(&(type_name.clone(), backend_id.clone())) {
70 continue;
71 }
72 let uid = backend_to_uid
73 .get(&(type_name.clone(), backend_id.clone()))
74 .copied()
75 .unwrap_or_else(|| uid_v5(type_name.as_str(), &key_string(&obs.key)));
76 ops.push(Op::Delete {
77 uid,
78 type_name: type_name.clone(),
79 key: obs.key.clone(),
80 backend_id: Some(backend_id.clone()),
81 });
82 }
83 }
84
85 ops.sort_by_key(op_order_key);
86
87 let mut plan = Plan {
88 schema: schema.clone(),
89 ops,
90 summary: None,
91 schema_preview: None,
92 };
93 plan.summary = Some(plan.summary());
94 plan
95}
96
97fn diff_attrs(
101 existing: &JsonMap,
102 desired: &JsonMap,
103 type_schema: Option<&TypeSchema>,
104) -> Vec<FieldChange> {
105 let mut changes = Vec::new();
106 for (field, to) in desired.iter() {
107 let from = existing.get(field).cloned().unwrap_or(Value::Null);
108 let field_type = type_schema.and_then(|ts| {
109 ts.fields
110 .get(field)
111 .or_else(|| ts.key.get(field))
112 .map(|fs| &fs.r#type)
113 });
114 if !field_values_equal(field_type, &from, to) {
115 changes.push(FieldChange {
116 field: field.clone(),
117 from,
118 to: to.clone(),
119 });
120 }
121 }
122 changes
123}
124
125fn diff_object(
126 existing: &crate::types::ObservedObject,
127 desired: &Object,
128 type_schema: Option<&TypeSchema>,
129) -> Vec<FieldChange> {
130 diff_attrs(&existing.attrs, &desired.attrs, type_schema)
131}
132
133fn field_values_equal(field_type: Option<&FieldType>, from: &Value, to: &Value) -> bool {
139 match field_type {
140 Some(FieldType::Int) => match (value_as_int(from), value_as_int(to)) {
141 (Some(a), Some(b)) => a == b,
142 _ => from == to,
143 },
144 Some(FieldType::Float) => match (value_as_float(from), value_as_float(to)) {
145 (Some(a), Some(b)) => a == b,
146 _ => from == to,
147 },
148 Some(FieldType::List { item }) => match (from, to) {
149 (Value::Array(a), Value::Array(b)) => {
150 a.len() == b.len()
151 && a.iter()
152 .zip(b)
153 .all(|(x, y)| field_values_equal(Some(item), x, y))
154 }
155 _ => from == to,
156 },
157 Some(FieldType::Map { value }) => match (from, to) {
158 (Value::Object(a), Value::Object(b)) => {
159 a.len() == b.len()
160 && a.iter().all(|(k, x)| {
161 b.get(k)
162 .is_some_and(|y| field_values_equal(Some(value), x, y))
163 })
164 }
165 _ => from == to,
166 },
167 _ => from == to,
168 }
169}
170
171fn value_as_int(value: &Value) -> Option<i128> {
174 match value {
175 Value::Number(n) => n
176 .as_u64()
177 .map(i128::from)
178 .or_else(|| n.as_i64().map(i128::from))
179 .or_else(|| {
180 n.as_f64()
181 .filter(|f| f.is_finite() && f.fract() == 0.0)
182 .map(|f| f as i128)
183 }),
184 Value::String(s) => s.trim().parse::<i128>().ok(),
185 _ => None,
186 }
187}
188
189fn value_as_float(value: &Value) -> Option<f64> {
191 match value {
192 Value::Number(n) => n.as_f64(),
193 Value::String(s) => s.trim().parse::<f64>().ok(),
194 _ => None,
195 }
196}
197
198fn op_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
200 (type_name.as_str().to_string(), key_string(key))
201}
202
203type OrderKey = (String, u8, String);
205
206fn op_order_key(op: &Op) -> OrderKey {
208 let (type_name, key, weight) = match op {
209 Op::Create {
210 type_name, desired, ..
211 } => (type_name.clone(), key_string(&desired.key), 0u8),
212 Op::Update {
213 type_name, desired, ..
214 } => (type_name.clone(), key_string(&desired.key), 1u8),
215 Op::Delete { type_name, key, .. } => (type_name.clone(), key_string(key), 2u8),
216 };
217 (type_name.as_str().to_string(), weight, key)
218}
219
220fn collect_refs_in_value(field_type: &FieldType, value: &Value, out: &mut BTreeSet<Uid>) {
223 match field_type {
224 FieldType::Ref { .. } => {
225 if let Some(uid) = value.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
226 out.insert(uid);
227 }
228 }
229 FieldType::ListRef { .. } => {
230 if let Value::Array(items) = value {
231 for item in items {
232 if let Some(uid) = item.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
233 out.insert(uid);
234 }
235 }
236 }
237 }
238 FieldType::List { item } => {
239 if let Value::Array(items) = value {
240 for item_value in items {
241 collect_refs_in_value(item, item_value, out);
242 }
243 }
244 }
245 FieldType::Map { value: inner } => {
246 if let Value::Object(map) = value {
247 for entry in map.values() {
248 collect_refs_in_value(inner, entry, out);
249 }
250 }
251 }
252 _ => {}
253 }
254}
255
256fn collect_referenced_uids(
260 type_schema: &TypeSchema,
261 attrs: &BTreeMap<String, Value>,
262) -> BTreeSet<Uid> {
263 let mut uids = BTreeSet::new();
264 for (field, value) in attrs {
265 if let Some(field_schema) = type_schema
266 .fields
267 .get(field)
268 .or_else(|| type_schema.key.get(field))
269 {
270 collect_refs_in_value(&field_schema.r#type, value, &mut uids);
271 }
272 }
273 uids
274}
275
276fn op_referenced_uids(op: &Op, schema: &Schema) -> BTreeSet<Uid> {
280 let Some(type_schema) = schema.types.get(op.type_name().as_str()) else {
281 return BTreeSet::new();
282 };
283 match op {
284 Op::Create { desired, .. } | Op::Update { desired, .. } => {
285 let mut uids = collect_referenced_uids(type_schema, &desired.attrs);
286 uids.extend(collect_referenced_uids(type_schema, &desired.key));
287 uids
288 }
289 Op::Delete { key, .. } => collect_referenced_uids(type_schema, key),
290 }
291}
292
293fn stable_toposort(ops: &[&Op], edges: &[BTreeSet<usize>]) -> Vec<usize> {
299 let n = ops.len();
300 let keys: Vec<OrderKey> = ops.iter().map(|&op| op_order_key(op)).collect();
301
302 let mut indegree = vec![0usize; n];
303 for succs in edges {
304 for &b in succs {
305 indegree[b] += 1;
306 }
307 }
308
309 let mut ready: BinaryHeap<Reverse<(OrderKey, usize)>> = BinaryHeap::new();
310 for (i, °) in indegree.iter().enumerate() {
311 if deg == 0 {
312 ready.push(Reverse((keys[i].clone(), i)));
313 }
314 }
315
316 let mut order = Vec::with_capacity(n);
317 while let Some(Reverse((_, i))) = ready.pop() {
318 order.push(i);
319 for &b in &edges[i] {
320 indegree[b] -= 1;
321 if indegree[b] == 0 {
322 ready.push(Reverse((keys[b].clone(), b)));
323 }
324 }
325 }
326
327 if order.len() < n {
330 let mut placed = vec![false; n];
331 for &i in &order {
332 placed[i] = true;
333 }
334 let mut remaining: Vec<usize> = (0..n).filter(|&i| !placed[i]).collect();
335 remaining.sort_by(|&a, &b| keys[a].cmp(&keys[b]).then(a.cmp(&b)));
336 order.extend(remaining);
337 }
338
339 order
340}
341
342fn ordered_by_refs(ops: &[&Op], schema: &Schema, reverse: bool) -> Vec<Op> {
347 let uid_to_node: BTreeMap<Uid, usize> = ops
348 .iter()
349 .enumerate()
350 .map(|(i, op)| (op.uid(), i))
351 .collect();
352
353 let mut edges: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); ops.len()];
354 for (i, &op) in ops.iter().enumerate() {
355 for referenced in op_referenced_uids(op, schema) {
356 let Some(&j) = uid_to_node.get(&referenced) else {
357 continue;
358 };
359 if i == j {
360 continue;
361 }
362 if reverse {
363 edges[i].insert(j); } else {
365 edges[j].insert(i); }
367 }
368 }
369
370 stable_toposort(ops, &edges)
371 .into_iter()
372 .map(|i| ops[i].clone())
373 .collect()
374}
375
376pub fn sort_ops_for_apply(ops: &[Op], schema: &Schema) -> Vec<Op> {
381 let (non_deletes, deletes): (Vec<&Op>, Vec<&Op>) =
382 ops.iter().partition(|op| !matches!(op, Op::Delete { .. }));
383
384 let mut result = ordered_by_refs(&non_deletes, schema, false);
385 result.extend(ordered_by_refs(&deletes, schema, true));
386 result
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::state::{StateData, StateStore};
393 use crate::types::{BackendId, ObservedObject, ObservedState};
394 use alembic_core::{
395 FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
396 };
397 use serde_json::json;
398 use std::collections::BTreeMap;
399
400 fn make_key(slug: &str) -> Key {
401 let mut k = BTreeMap::new();
402 k.insert("slug".to_string(), json!(slug));
403 Key::from(k)
404 }
405
406 fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
407 let mut m = BTreeMap::new();
408 for (k, v) in pairs {
409 m.insert(k.to_string(), v.clone());
410 }
411 JsonMap::from(m)
412 }
413
414 fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
415 Object::new(
416 Uid::from_u128(uid),
417 TypeName::new(type_name),
418 make_key(slug),
419 attrs,
420 )
421 .unwrap()
422 }
423
424 fn empty_schema() -> Schema {
425 Schema {
426 types: BTreeMap::new(),
427 }
428 }
429
430 fn empty_state() -> StateStore {
431 StateStore::new(None, StateData::default())
432 }
433
434 #[test]
437 fn diff_attrs_identical_maps() {
438 let attrs = make_attrs(&[("name", json!("FRA1"))]);
439 let changes = diff_attrs(&attrs, &attrs, None);
440 assert!(changes.is_empty());
441 }
442
443 fn int_field_schema(name: &str) -> TypeSchema {
444 TypeSchema {
445 key: BTreeMap::new(),
446 fields: BTreeMap::from([(name.to_string(), field(FieldType::Int))]),
447 }
448 }
449
450 #[test]
451 fn diff_attrs_int_field_ignores_int_float_representation() {
452 let existing = make_attrs(&[("count", json!(3.0))]);
453 let desired = make_attrs(&[("count", json!(3))]);
454 let changes = diff_attrs(&existing, &desired, Some(&int_field_schema("count")));
455 assert!(changes.is_empty(), "{changes:?}");
456 }
457
458 #[test]
459 fn diff_attrs_int_field_ignores_numeric_string() {
460 let existing = make_attrs(&[("count", json!("3"))]);
461 let desired = make_attrs(&[("count", json!(3))]);
462 let changes = diff_attrs(&existing, &desired, Some(&int_field_schema("count")));
463 assert!(changes.is_empty(), "{changes:?}");
464 }
465
466 #[test]
467 fn diff_attrs_int_field_still_detects_real_change() {
468 let existing = make_attrs(&[("count", json!(3))]);
469 let desired = make_attrs(&[("count", json!(4))]);
470 let changes = diff_attrs(&existing, &desired, Some(&int_field_schema("count")));
471 assert_eq!(changes.len(), 1);
472 }
473
474 #[test]
475 fn diff_attrs_without_schema_compares_raw() {
476 let existing = make_attrs(&[("count", json!(3.0))]);
478 let desired = make_attrs(&[("count", json!(3))]);
479 let changes = diff_attrs(&existing, &desired, None);
480 assert_eq!(changes.len(), 1);
481 }
482
483 #[test]
484 fn diff_attrs_float_field_ignores_int_float_representation() {
485 let ts = TypeSchema {
486 key: BTreeMap::new(),
487 fields: BTreeMap::from([("ratio".to_string(), field(FieldType::Float))]),
488 };
489 let existing = make_attrs(&[("ratio", json!(2))]);
490 let desired = make_attrs(&[("ratio", json!(2.0))]);
491 let changes = diff_attrs(&existing, &desired, Some(&ts));
492 assert!(changes.is_empty(), "{changes:?}");
493 }
494
495 #[test]
496 fn diff_attrs_list_of_int_ignores_representation() {
497 let ts = TypeSchema {
498 key: BTreeMap::new(),
499 fields: BTreeMap::from([(
500 "ports".to_string(),
501 field(FieldType::List {
502 item: Box::new(FieldType::Int),
503 }),
504 )]),
505 };
506 let existing = make_attrs(&[("ports", json!([1.0, 2.0]))]);
507 let desired = make_attrs(&[("ports", json!([1, 2]))]);
508 let changes = diff_attrs(&existing, &desired, Some(&ts));
509 assert!(changes.is_empty(), "{changes:?}");
510 let desired2 = make_attrs(&[("ports", json!([1, 9]))]);
512 assert_eq!(diff_attrs(&existing, &desired2, Some(&ts)).len(), 1);
513 }
514
515 #[test]
516 fn diff_attrs_field_changed() {
517 let existing = make_attrs(&[("name", json!("FRA1"))]);
518 let desired = make_attrs(&[("name", json!("FRA2"))]);
519 let changes = diff_attrs(&existing, &desired, None);
520 assert_eq!(changes.len(), 1);
521 assert_eq!(changes[0].field, "name");
522 assert_eq!(changes[0].from, json!("FRA1"));
523 assert_eq!(changes[0].to, json!("FRA2"));
524 }
525
526 #[test]
527 fn diff_attrs_field_added() {
528 let existing = make_attrs(&[]);
529 let desired = make_attrs(&[("name", json!("FRA1"))]);
530 let changes = diff_attrs(&existing, &desired, None);
531 assert_eq!(changes.len(), 1);
532 assert_eq!(changes[0].field, "name");
533 assert_eq!(changes[0].from, json!(null));
534 assert_eq!(changes[0].to, json!("FRA1"));
535 }
536
537 #[test]
538 fn diff_attrs_field_removed_in_desired_is_ignored() {
539 let existing = make_attrs(&[("name", json!("FRA1")), ("extra", json!(true))]);
540 let desired = make_attrs(&[("name", json!("FRA1"))]);
541 let changes = diff_attrs(&existing, &desired, None);
542 assert!(changes.is_empty());
543 }
544
545 #[test]
546 fn diff_attrs_multiple_changes() {
547 let existing = make_attrs(&[("a", json!(1)), ("b", json!(2))]);
548 let desired = make_attrs(&[("a", json!(10)), ("b", json!(20))]);
549 let changes = diff_attrs(&existing, &desired, None);
550 assert_eq!(changes.len(), 2);
551 let fields: Vec<&str> = changes.iter().map(|c| c.field.as_str()).collect();
552 assert!(fields.contains(&"a"));
553 assert!(fields.contains(&"b"));
554 }
555
556 #[test]
559 fn plan_creates_for_new_objects() {
560 let desired = vec![make_object(
561 1,
562 "dcim.site",
563 "fra1",
564 make_attrs(&[("name", json!("FRA1"))]),
565 )];
566 let observed = ObservedState::default();
567 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
568 assert_eq!(result.ops.len(), 1);
569 assert!(matches!(&result.ops[0], Op::Create { uid, type_name, .. }
570 if *uid == Uid::from_u128(1) && type_name.as_str() == "dcim.site"));
571 let summary = result.summary.unwrap();
572 assert_eq!(summary.create, 1);
573 assert_eq!(summary.update, 0);
574 assert_eq!(summary.delete, 0);
575 }
576
577 #[test]
578 fn plan_updates_when_attrs_differ() {
579 let desired = vec![make_object(
580 1,
581 "dcim.site",
582 "fra1",
583 make_attrs(&[("name", json!("FRA2"))]),
584 )];
585 let mut observed = ObservedState::default();
586 observed
587 .insert(ObservedObject {
588 type_name: TypeName::new("dcim.site"),
589 key: make_key("fra1"),
590 attrs: make_attrs(&[("name", json!("FRA1"))]),
591 backend_id: Some(BackendId::Int(100)),
592 })
593 .unwrap();
594 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
595 assert_eq!(result.ops.len(), 1);
596 match &result.ops[0] {
597 Op::Update {
598 changes,
599 backend_id,
600 ..
601 } => {
602 assert_eq!(changes.len(), 1);
603 assert_eq!(changes[0].field, "name");
604 assert_eq!(backend_id, &Some(BackendId::Int(100)));
605 }
606 other => panic!("expected Update, got {:?}", other),
607 }
608 }
609
610 #[test]
611 fn plan_no_op_when_identical() {
612 let desired = vec![make_object(
613 1,
614 "dcim.site",
615 "fra1",
616 make_attrs(&[("name", json!("FRA1"))]),
617 )];
618 let mut observed = ObservedState::default();
619 observed
620 .insert(ObservedObject {
621 type_name: TypeName::new("dcim.site"),
622 key: make_key("fra1"),
623 attrs: make_attrs(&[("name", json!("FRA1"))]),
624 backend_id: Some(BackendId::Int(100)),
625 })
626 .unwrap();
627 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
628 assert!(result.ops.is_empty());
629 }
630
631 #[test]
632 fn plan_deletes_unmatched_when_allowed() {
633 let desired = vec![];
634 let mut observed = ObservedState::default();
635 observed
636 .insert(ObservedObject {
637 type_name: TypeName::new("dcim.site"),
638 key: make_key("fra1"),
639 attrs: make_attrs(&[("name", json!("FRA1"))]),
640 backend_id: Some(BackendId::Int(100)),
641 })
642 .unwrap();
643 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
644 assert_eq!(result.ops.len(), 1);
645 assert!(matches!(
646 &result.ops[0],
647 Op::Delete {
648 backend_id: Some(BackendId::Int(100)),
649 ..
650 }
651 ));
652 }
653
654 #[test]
655 fn plan_no_deletes_when_disallowed() {
656 let desired = vec![];
657 let mut observed = ObservedState::default();
658 observed
659 .insert(ObservedObject {
660 type_name: TypeName::new("dcim.site"),
661 key: make_key("fra1"),
662 attrs: make_attrs(&[("name", json!("FRA1"))]),
663 backend_id: Some(BackendId::Int(100)),
664 })
665 .unwrap();
666 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
667 assert!(result.ops.is_empty());
668 }
669
670 #[test]
671 fn plan_matched_objects_not_deleted() {
672 let desired = vec![make_object(
673 1,
674 "dcim.site",
675 "fra1",
676 make_attrs(&[("name", json!("FRA1"))]),
677 )];
678 let mut observed = ObservedState::default();
679 observed
680 .insert(ObservedObject {
681 type_name: TypeName::new("dcim.site"),
682 key: make_key("fra1"),
683 attrs: make_attrs(&[("name", json!("FRA1"))]),
684 backend_id: Some(BackendId::Int(100)),
685 })
686 .unwrap();
687 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
688 assert!(result.ops.is_empty());
689 }
690
691 #[test]
692 fn plan_deletes_cross_type_id_collision() {
693 let desired = vec![make_object(
696 1,
697 "dcim.site",
698 "fra1",
699 make_attrs(&[("name", json!("FRA1"))]),
700 )];
701 let mut observed = ObservedState::default();
702 observed
703 .insert(ObservedObject {
704 type_name: TypeName::new("dcim.site"),
705 key: make_key("fra1"),
706 attrs: make_attrs(&[("name", json!("FRA1"))]),
707 backend_id: Some(BackendId::Int(100)),
708 })
709 .unwrap();
710 observed
711 .insert(ObservedObject {
712 type_name: TypeName::new("dcim.device"),
713 key: make_key("leaf01"),
714 attrs: make_attrs(&[]),
715 backend_id: Some(BackendId::Int(100)),
716 })
717 .unwrap();
718 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
719 let deletes: Vec<&Op> = result
720 .ops
721 .iter()
722 .filter(|op| matches!(op, Op::Delete { .. }))
723 .collect();
724 assert_eq!(deletes.len(), 1);
725 assert!(matches!(
726 deletes[0],
727 Op::Delete { type_name, .. } if type_name.as_str() == "dcim.device"
728 ));
729 }
730
731 #[test]
732 fn plan_mixed_create_update_delete() {
733 let desired = vec![
734 make_object(
735 1,
736 "dcim.site",
737 "fra1",
738 make_attrs(&[("name", json!("FRA1-new"))]),
739 ),
740 make_object(
741 2,
742 "dcim.site",
743 "ams1",
744 make_attrs(&[("name", json!("AMS1"))]),
745 ),
746 ];
747 let mut observed = ObservedState::default();
748 observed
749 .insert(ObservedObject {
750 type_name: TypeName::new("dcim.site"),
751 key: make_key("fra1"),
752 attrs: make_attrs(&[("name", json!("FRA1"))]),
753 backend_id: Some(BackendId::Int(100)),
754 })
755 .unwrap();
756 observed
757 .insert(ObservedObject {
758 type_name: TypeName::new("dcim.site"),
759 key: make_key("lhr1"),
760 attrs: make_attrs(&[("name", json!("LHR1"))]),
761 backend_id: Some(BackendId::Int(200)),
762 })
763 .unwrap();
764 let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
765
766 let creates: Vec<_> = result
767 .ops
768 .iter()
769 .filter(|op| matches!(op, Op::Create { .. }))
770 .collect();
771 let updates: Vec<_> = result
772 .ops
773 .iter()
774 .filter(|op| matches!(op, Op::Update { .. }))
775 .collect();
776 let deletes: Vec<_> = result
777 .ops
778 .iter()
779 .filter(|op| matches!(op, Op::Delete { .. }))
780 .collect();
781 assert_eq!(creates.len(), 1);
782 assert_eq!(updates.len(), 1);
783 assert_eq!(deletes.len(), 1);
784
785 let summary = result.summary.unwrap();
786 assert_eq!(summary.create, 1);
787 assert_eq!(summary.update, 1);
788 assert_eq!(summary.delete, 1);
789 }
790
791 #[test]
792 fn plan_uses_state_mapping_for_lookup() {
793 let mut state_data = StateData::default();
794 state_data
795 .mappings
796 .entry(TypeName::new("dcim.site"))
797 .or_default()
798 .insert(Uid::from_u128(1), BackendId::Int(100));
799 let state = StateStore::new(None, state_data);
800
801 let desired = vec![make_object(
802 1,
803 "dcim.site",
804 "fra1",
805 make_attrs(&[("name", json!("FRA2"))]),
806 )];
807 let mut observed = ObservedState::default();
808 observed
809 .insert(ObservedObject {
810 type_name: TypeName::new("dcim.site"),
811 key: make_key("fra1"),
812 attrs: make_attrs(&[("name", json!("FRA1"))]),
813 backend_id: Some(BackendId::Int(100)),
814 })
815 .unwrap();
816 let result = plan(&desired, &observed, &state, &empty_schema(), false);
817 assert_eq!(result.ops.len(), 1);
818 assert!(matches!(&result.ops[0], Op::Update { .. }));
819 }
820
821 #[test]
822 fn plan_matches_renamed_object_by_backend_id() {
823 let mut state_data = StateData::default();
824 state_data
825 .mappings
826 .entry(TypeName::new("dcim.site"))
827 .or_default()
828 .insert(Uid::from_u128(1), BackendId::Int(100));
829 let state = StateStore::new(None, state_data);
830
831 let desired = vec![make_object(
832 1,
833 "dcim.site",
834 "fra1-renamed",
835 make_attrs(&[("name", json!("FRA2"))]),
836 )];
837 let mut observed = ObservedState::default();
838 observed
839 .insert(ObservedObject {
840 type_name: TypeName::new("dcim.site"),
841 key: make_key("fra1-old"),
842 attrs: make_attrs(&[("name", json!("FRA1"))]),
843 backend_id: Some(BackendId::Int(100)),
844 })
845 .unwrap();
846 let result = plan(&desired, &observed, &state, &empty_schema(), true);
847 assert_eq!(result.ops.len(), 1);
848 assert!(matches!(
849 &result.ops[0],
850 Op::Update {
851 backend_id: Some(BackendId::Int(100)),
852 ..
853 }
854 ));
855 }
856
857 #[test]
858 fn plan_delete_uses_tracked_uid() {
859 let mut state_data = StateData::default();
860 state_data
861 .mappings
862 .entry(TypeName::new("dcim.site"))
863 .or_default()
864 .insert(Uid::from_u128(42), BackendId::Int(100));
865 let state = StateStore::new(None, state_data);
866
867 let mut observed = ObservedState::default();
868 observed
869 .insert(ObservedObject {
870 type_name: TypeName::new("dcim.site"),
871 key: make_key("fra1"),
872 attrs: make_attrs(&[]),
873 backend_id: Some(BackendId::Int(100)),
874 })
875 .unwrap();
876
877 let result = plan(&[], &observed, &state, &empty_schema(), true);
878 assert_eq!(result.ops.len(), 1);
879 match &result.ops[0] {
880 Op::Delete { uid, .. } => {
881 assert_eq!(*uid, Uid::from_u128(42));
882 assert_ne!(*uid, uid_v5("dcim.site", &key_string(&make_key("fra1"))));
883 }
884 other => panic!("expected delete, got {other:?}"),
885 }
886 }
887
888 #[test]
891 fn sort_ops_creates_before_deletes() {
892 let ops = vec![
893 Op::Delete {
894 uid: Uid::from_u128(1),
895 type_name: TypeName::new("dcim.site"),
896 key: make_key("fra1"),
897 backend_id: Some(BackendId::Int(100)),
898 },
899 Op::Create {
900 uid: Uid::from_u128(2),
901 type_name: TypeName::new("dcim.site"),
902 desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
903 },
904 ];
905 let sorted = sort_ops_for_apply(&ops, &empty_schema());
906 assert!(matches!(&sorted[0], Op::Create { .. }));
907 assert!(matches!(&sorted[1], Op::Delete { .. }));
908 }
909
910 #[test]
911 fn sort_ops_deletes_last() {
912 let ops = vec![
913 Op::Delete {
914 uid: Uid::from_u128(1),
915 type_name: TypeName::new("a.type"),
916 key: make_key("a"),
917 backend_id: None,
918 },
919 Op::Delete {
920 uid: Uid::from_u128(2),
921 type_name: TypeName::new("z.type"),
922 key: make_key("z"),
923 backend_id: None,
924 },
925 ];
926 let sorted = sort_ops_for_apply(&ops, &empty_schema());
927 assert!(
929 matches!(&sorted[0], Op::Delete { type_name, .. } if type_name.as_str() == "a.type")
930 );
931 assert!(
932 matches!(&sorted[1], Op::Delete { type_name, .. } if type_name.as_str() == "z.type")
933 );
934 }
935
936 #[test]
937 fn sort_ops_empty_input() {
938 let sorted = sort_ops_for_apply(&[], &empty_schema());
939 assert!(sorted.is_empty());
940 }
941
942 #[test]
943 fn sort_ops_preserves_create_update_order() {
944 let ops = vec![
945 Op::Update {
946 uid: Uid::from_u128(2),
947 type_name: TypeName::new("dcim.site"),
948 desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
949 changes: vec![],
950 backend_id: None,
951 },
952 Op::Create {
953 uid: Uid::from_u128(1),
954 type_name: TypeName::new("dcim.site"),
955 desired: make_object(1, "dcim.site", "aaa1", make_attrs(&[])),
956 },
957 ];
958 let sorted = sort_ops_for_apply(&ops, &empty_schema());
959 assert!(matches!(&sorted[0], Op::Create { .. }));
960 assert!(matches!(&sorted[1], Op::Update { .. }));
961 }
962
963 fn field(r#type: FieldType) -> FieldSchema {
966 FieldSchema {
967 r#type,
968 required: false,
969 nullable: true,
970 format: None,
971 pattern: None,
972 description: None,
973 }
974 }
975
976 fn ref_t() -> FieldType {
977 FieldType::Ref {
978 target: "node".to_string(),
979 }
980 }
981
982 fn list_ref_t() -> FieldType {
983 FieldType::ListRef {
984 target: "node".to_string(),
985 }
986 }
987
988 fn node_schema(fields: &[(&str, FieldType)]) -> Schema {
990 let mut field_map = BTreeMap::new();
991 for (name, ty) in fields {
992 field_map.insert(name.to_string(), field(ty.clone()));
993 }
994 let mut key = BTreeMap::new();
995 key.insert("slug".to_string(), field(FieldType::Slug));
996 let mut types = BTreeMap::new();
997 types.insert(
998 "node".to_string(),
999 TypeSchema {
1000 key,
1001 fields: field_map,
1002 },
1003 );
1004 Schema { types }
1005 }
1006
1007 fn uref(uid: u128) -> serde_json::Value {
1009 json!(Uid::from_u128(uid).to_string())
1010 }
1011
1012 fn create_node(uid: u128, slug: &str, attrs: JsonMap) -> Op {
1013 Op::Create {
1014 uid: Uid::from_u128(uid),
1015 type_name: TypeName::new("node"),
1016 desired: make_object(uid, "node", slug, attrs),
1017 }
1018 }
1019
1020 fn order_index(ops: &[Op], uid: u128) -> usize {
1022 ops.iter()
1023 .position(|op| op.uid().as_u128() == uid)
1024 .expect("uid present in ordered ops")
1025 }
1026
1027 #[test]
1028 fn toposort_linear_ref_chain() {
1029 let schema = node_schema(&[("next", ref_t())]);
1030 let ops = vec![
1031 create_node(1, "a", make_attrs(&[("next", uref(2))])),
1032 create_node(2, "b", make_attrs(&[("next", uref(3))])),
1033 create_node(3, "c", make_attrs(&[])),
1034 ];
1035 let sorted = sort_ops_for_apply(&ops, &schema);
1036 assert!(order_index(&sorted, 3) < order_index(&sorted, 2));
1038 assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
1039 }
1040
1041 #[test]
1042 fn toposort_diamond_shared_dep() {
1043 let schema = node_schema(&[("parent", ref_t()), ("left", ref_t()), ("right", ref_t())]);
1044 let ops = vec![
1045 create_node(1, "d", make_attrs(&[("left", uref(2)), ("right", uref(3))])),
1046 create_node(2, "b", make_attrs(&[("parent", uref(4))])),
1047 create_node(3, "c", make_attrs(&[("parent", uref(4))])),
1048 create_node(4, "a", make_attrs(&[])),
1049 ];
1050 let sorted = sort_ops_for_apply(&ops, &schema);
1051 let (a, b, c, d) = (
1052 order_index(&sorted, 4),
1053 order_index(&sorted, 2),
1054 order_index(&sorted, 3),
1055 order_index(&sorted, 1),
1056 );
1057 assert!(a < b && a < c, "shared dep created first");
1058 assert!(b < d && c < d, "both branches created before the joiner");
1059 }
1060
1061 #[test]
1062 fn toposort_list_ref_fan_out() {
1063 let schema = node_schema(&[("members", list_ref_t())]);
1064 let ops = vec![
1065 create_node(
1066 1,
1067 "a",
1068 make_attrs(&[("members", json!([uref(2), uref(3), uref(4)]))]),
1069 ),
1070 create_node(2, "b", make_attrs(&[])),
1071 create_node(3, "c", make_attrs(&[])),
1072 create_node(4, "d", make_attrs(&[])),
1073 ];
1074 let sorted = sort_ops_for_apply(&ops, &schema);
1075 let a = order_index(&sorted, 1);
1076 assert!(order_index(&sorted, 2) < a);
1077 assert!(order_index(&sorted, 3) < a);
1078 assert!(order_index(&sorted, 4) < a);
1079 }
1080
1081 #[test]
1082 fn toposort_refs_nested_in_list_and_map() {
1083 let schema = node_schema(&[
1084 (
1085 "group",
1086 FieldType::List {
1087 item: Box::new(ref_t()),
1088 },
1089 ),
1090 (
1091 "lookup",
1092 FieldType::Map {
1093 value: Box::new(ref_t()),
1094 },
1095 ),
1096 ]);
1097 let ops = vec![
1098 create_node(
1099 1,
1100 "a",
1101 make_attrs(&[
1102 ("group", json!([uref(2)])),
1103 ("lookup", json!({ "k": uref(3) })),
1104 ]),
1105 ),
1106 create_node(2, "b", make_attrs(&[])),
1107 create_node(3, "c", make_attrs(&[])),
1108 ];
1109 let sorted = sort_ops_for_apply(&ops, &schema);
1110 let a = order_index(&sorted, 1);
1111 assert!(order_index(&sorted, 2) < a, "ref nested in list");
1112 assert!(order_index(&sorted, 3) < a, "ref nested in map");
1113 }
1114
1115 #[test]
1116 fn toposort_reference_cycle_falls_back_to_stable_order() {
1117 let schema = node_schema(&[("next", ref_t())]);
1118 let ops = vec![
1119 create_node(1, "aaa", make_attrs(&[("next", uref(2))])),
1120 create_node(2, "bbb", make_attrs(&[("next", uref(1))])),
1121 ];
1122 let sorted = sort_ops_for_apply(&ops, &schema);
1123 assert_eq!(sorted.len(), 2);
1125 assert_eq!(order_index(&sorted, 1), 0);
1127 assert_eq!(order_index(&sorted, 2), 1);
1128 }
1129
1130 #[test]
1131 fn reverse_toposort_deletes_child_before_parent() {
1132 let mut node_key = BTreeMap::new();
1135 node_key.insert("slug".to_string(), field(FieldType::Slug));
1136 let mut child_key = BTreeMap::new();
1137 child_key.insert("parent".to_string(), field(ref_t()));
1138 child_key.insert("slug".to_string(), field(FieldType::Slug));
1139 let mut types = BTreeMap::new();
1140 types.insert(
1141 "node".to_string(),
1142 TypeSchema {
1143 key: node_key,
1144 fields: BTreeMap::new(),
1145 },
1146 );
1147 types.insert(
1148 "child".to_string(),
1149 TypeSchema {
1150 key: child_key,
1151 fields: BTreeMap::new(),
1152 },
1153 );
1154 let schema = Schema { types };
1155
1156 let parent_key = Key::from(BTreeMap::from([("slug".to_string(), json!("p"))]));
1157 let child_key_val = Key::from(BTreeMap::from([
1158 ("parent".to_string(), uref(1)),
1159 ("slug".to_string(), json!("c")),
1160 ]));
1161 let ops = vec![
1162 Op::Delete {
1163 uid: Uid::from_u128(1),
1164 type_name: TypeName::new("node"),
1165 key: parent_key,
1166 backend_id: None,
1167 },
1168 Op::Delete {
1169 uid: Uid::from_u128(2),
1170 type_name: TypeName::new("child"),
1171 key: child_key_val,
1172 backend_id: None,
1173 },
1174 ];
1175 let sorted = sort_ops_for_apply(&ops, &schema);
1176 assert!(
1177 order_index(&sorted, 2) < order_index(&sorted, 1),
1178 "child deleted before the parent it references"
1179 );
1180 assert!(sorted.iter().all(|op| matches!(op, Op::Delete { .. })));
1181 }
1182
1183 #[test]
1184 fn toposort_keeps_deletes_after_creates_and_updates() {
1185 let schema = node_schema(&[("next", ref_t())]);
1186 let ops = vec![
1187 Op::Delete {
1188 uid: Uid::from_u128(9),
1189 type_name: TypeName::new("node"),
1190 key: make_key("z"),
1191 backend_id: None,
1192 },
1193 create_node(1, "a", make_attrs(&[("next", uref(2))])),
1194 create_node(2, "b", make_attrs(&[])),
1195 ];
1196 let sorted = sort_ops_for_apply(&ops, &schema);
1197 let non_delete_count = sorted
1198 .iter()
1199 .filter(|op| !matches!(op, Op::Delete { .. }))
1200 .count();
1201 let delete_pos = sorted
1202 .iter()
1203 .position(|op| matches!(op, Op::Delete { .. }))
1204 .unwrap();
1205 assert_eq!(delete_pos, non_delete_count);
1207 assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
1209 }
1210}