Skip to main content

alembic_engine/
planner.rs

1//! diff and plan generation.
2
3use 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
12/// build a deterministic plan from desired and observed state.
13pub 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((backend_id.clone(), type_name.clone()), *uid);
27        }
28    }
29
30    let mut desired_sorted = desired.to_vec();
31    desired_sorted.sort_by_key(|a| op_sort_key(&a.type_name, &a.key));
32
33    for object in desired_sorted.iter() {
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(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(backend_id) {
69                continue;
70            }
71            let uid = backend_to_uid
72                .get(&(backend_id.clone(), type_name.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    };
91    plan.summary = Some(plan.summary());
92    plan
93}
94
95/// compute field-level diffs for attrs.
96fn diff_attrs(existing: &JsonMap, desired: &JsonMap) -> Vec<FieldChange> {
97    let mut changes = Vec::new();
98    let keys: BTreeSet<String> = existing.keys().chain(desired.keys()).cloned().collect();
99
100    for key in keys.iter() {
101        let from = existing.get(key).cloned().unwrap_or(Value::Null);
102        let desired_has = desired.contains_key(key);
103        if !desired_has {
104            continue;
105        }
106        let to = desired.get(key).cloned().unwrap_or(Value::Null);
107        if from != to {
108            changes.push(FieldChange {
109                field: key.clone(),
110                from,
111                to,
112            });
113        }
114    }
115
116    changes
117}
118
119fn diff_object(existing: &crate::types::ObservedObject, desired: &Object) -> Vec<FieldChange> {
120    diff_attrs(&existing.attrs, &desired.attrs)
121}
122
123/// stable sort key for desired objects.
124fn op_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
125    (type_name.as_str().to_string(), key_string(key))
126}
127
128/// deterministic ordering key for a plan op: (type name, op weight, key string).
129type OrderKey = (String, u8, String);
130
131/// stable sort key for plan operations.
132fn op_order_key(op: &Op) -> OrderKey {
133    let (type_name, key, weight) = match op {
134        Op::Create {
135            type_name, desired, ..
136        } => (type_name.clone(), key_string(&desired.key), 0u8),
137        Op::Update {
138            type_name, desired, ..
139        } => (type_name.clone(), key_string(&desired.key), 1u8),
140        Op::Delete { type_name, key, .. } => (type_name.clone(), key_string(key), 2u8),
141    };
142    (type_name.as_str().to_string(), weight, key)
143}
144
145/// collect the referenced uids carried by a value, recursing through list and
146/// map containers, using the field's schema to know where refs live.
147fn collect_refs_in_value(field_type: &FieldType, value: &Value, out: &mut BTreeSet<Uid>) {
148    match field_type {
149        FieldType::Ref { .. } => {
150            if let Some(uid) = value.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
151                out.insert(uid);
152            }
153        }
154        FieldType::ListRef { .. } => {
155            if let Value::Array(items) = value {
156                for item in items {
157                    if let Some(uid) = item.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
158                        out.insert(uid);
159                    }
160                }
161            }
162        }
163        FieldType::List { item } => {
164            if let Value::Array(items) = value {
165                for item_value in items {
166                    collect_refs_in_value(item, item_value, out);
167                }
168            }
169        }
170        FieldType::Map { value: inner } => {
171            if let Value::Object(map) = value {
172                for entry in map.values() {
173                    collect_refs_in_value(inner, entry, out);
174                }
175            }
176        }
177        _ => {}
178    }
179}
180
181/// collect every uid referenced by an object's attrs (or key) given its schema.
182/// fields are looked up in `fields` first, falling back to `key` so that
183/// reference-typed key components are picked up too.
184fn collect_referenced_uids(
185    type_schema: &TypeSchema,
186    attrs: &BTreeMap<String, Value>,
187) -> BTreeSet<Uid> {
188    let mut uids = BTreeSet::new();
189    for (field, value) in attrs {
190        if let Some(field_schema) = type_schema
191            .fields
192            .get(field)
193            .or_else(|| type_schema.key.get(field))
194        {
195            collect_refs_in_value(&field_schema.r#type, value, &mut uids);
196        }
197    }
198    uids
199}
200
201/// the uids an op depends on. creates/updates draw refs from the desired attrs
202/// and key; deletes only carry a key, so refs come from reference-typed key
203/// components (the op has no attrs to inspect).
204fn op_referenced_uids(op: &Op, schema: &Schema) -> BTreeSet<Uid> {
205    let Some(type_schema) = schema.types.get(op.type_name().as_str()) else {
206        return BTreeSet::new();
207    };
208    match op {
209        Op::Create { desired, .. } | Op::Update { desired, .. } => {
210            let mut uids = collect_referenced_uids(type_schema, &desired.attrs);
211            uids.extend(collect_referenced_uids(type_schema, &desired.key));
212            uids
213        }
214        Op::Delete { key, .. } => collect_referenced_uids(type_schema, key),
215    }
216}
217
218/// Kahn's algorithm with `op_order_key` as a deterministic tie-breaker.
219/// `edges[a]` holds the nodes that must come *after* node `a`. Nodes that
220/// remain in a reference cycle never reach in-degree zero; they are appended in
221/// stable `op_order_key` order so the result is always a total order (the
222/// apply_retry fixpoint resolves any residual ordering for them at apply time).
223fn stable_toposort(ops: &[&Op], edges: &[BTreeSet<usize>]) -> Vec<usize> {
224    let n = ops.len();
225    let keys: Vec<OrderKey> = ops.iter().map(|&op| op_order_key(op)).collect();
226
227    let mut indegree = vec![0usize; n];
228    for succs in edges {
229        for &b in succs {
230            indegree[b] += 1;
231        }
232    }
233
234    let mut ready: BinaryHeap<Reverse<(OrderKey, usize)>> = BinaryHeap::new();
235    for (i, &deg) in indegree.iter().enumerate() {
236        if deg == 0 {
237            ready.push(Reverse((keys[i].clone(), i)));
238        }
239    }
240
241    let mut order = Vec::with_capacity(n);
242    while let Some(Reverse((_, i))) = ready.pop() {
243        order.push(i);
244        for &b in &edges[i] {
245            indegree[b] -= 1;
246            if indegree[b] == 0 {
247                ready.push(Reverse((keys[b].clone(), b)));
248            }
249        }
250    }
251
252    // cyclic remainder: nodes that never reached in-degree zero. fall back to
253    // stable ordering for them so we still emit a total order without panicking.
254    if order.len() < n {
255        let mut placed = vec![false; n];
256        for &i in &order {
257            placed[i] = true;
258        }
259        let mut remaining: Vec<usize> = (0..n).filter(|&i| !placed[i]).collect();
260        remaining.sort_by(|&a, &b| keys[a].cmp(&keys[b]).then(a.cmp(&b)));
261        order.extend(remaining);
262    }
263
264    order
265}
266
267/// order ops by reference dependency. when `reverse` is false (creates/updates)
268/// an op is placed after the ops that create the uids it references; when true
269/// (deletes) an op is placed before the ops it references, so an object is
270/// removed only after everything referencing it.
271fn ordered_by_refs(ops: &[&Op], schema: &Schema, reverse: bool) -> Vec<Op> {
272    let uid_to_node: BTreeMap<Uid, usize> = ops
273        .iter()
274        .enumerate()
275        .map(|(i, op)| (op.uid(), i))
276        .collect();
277
278    let mut edges: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); ops.len()];
279    for (i, &op) in ops.iter().enumerate() {
280        for referenced in op_referenced_uids(op, schema) {
281            let Some(&j) = uid_to_node.get(&referenced) else {
282                continue;
283            };
284            if i == j {
285                continue;
286            }
287            if reverse {
288                edges[i].insert(j); // i references j -> delete i before j
289            } else {
290                edges[j].insert(i); // i references j -> create j before i
291            }
292        }
293    }
294
295    stable_toposort(ops, &edges)
296        .into_iter()
297        .map(|i| ops[i].clone())
298        .collect()
299}
300
301/// order operations for apply: creates/updates first (topologically sorted so
302/// referenced objects are created before the objects that reference them),
303/// then deletes (reverse-toposorted so an object is deleted only after
304/// everything referencing it). reference cycles fall back to a stable order.
305pub fn sort_ops_for_apply(ops: &[Op], schema: &Schema) -> Vec<Op> {
306    let (non_deletes, deletes): (Vec<&Op>, Vec<&Op>) =
307        ops.iter().partition(|op| !matches!(op, Op::Delete { .. }));
308
309    let mut result = ordered_by_refs(&non_deletes, schema, false);
310    result.extend(ordered_by_refs(&deletes, schema, true));
311    result
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::state::{StateData, StateStore};
318    use crate::types::{BackendId, ObservedObject, ObservedState};
319    use alembic_core::{
320        FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
321    };
322    use serde_json::json;
323    use std::collections::BTreeMap;
324
325    fn make_key(slug: &str) -> Key {
326        let mut k = BTreeMap::new();
327        k.insert("slug".to_string(), json!(slug));
328        Key::from(k)
329    }
330
331    fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
332        let mut m = BTreeMap::new();
333        for (k, v) in pairs {
334            m.insert(k.to_string(), v.clone());
335        }
336        JsonMap::from(m)
337    }
338
339    fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
340        Object::new(
341            Uid::from_u128(uid),
342            TypeName::new(type_name),
343            make_key(slug),
344            attrs,
345        )
346        .unwrap()
347    }
348
349    fn empty_schema() -> Schema {
350        Schema {
351            types: BTreeMap::new(),
352        }
353    }
354
355    fn empty_state() -> StateStore {
356        StateStore::new(None, StateData::default())
357    }
358
359    // --- diff_attrs ---
360
361    #[test]
362    fn diff_attrs_identical_maps() {
363        let attrs = make_attrs(&[("name", json!("FRA1"))]);
364        let changes = diff_attrs(&attrs, &attrs);
365        assert!(changes.is_empty());
366    }
367
368    #[test]
369    fn diff_attrs_field_changed() {
370        let existing = make_attrs(&[("name", json!("FRA1"))]);
371        let desired = make_attrs(&[("name", json!("FRA2"))]);
372        let changes = diff_attrs(&existing, &desired);
373        assert_eq!(changes.len(), 1);
374        assert_eq!(changes[0].field, "name");
375        assert_eq!(changes[0].from, json!("FRA1"));
376        assert_eq!(changes[0].to, json!("FRA2"));
377    }
378
379    #[test]
380    fn diff_attrs_field_added() {
381        let existing = make_attrs(&[]);
382        let desired = make_attrs(&[("name", json!("FRA1"))]);
383        let changes = diff_attrs(&existing, &desired);
384        assert_eq!(changes.len(), 1);
385        assert_eq!(changes[0].field, "name");
386        assert_eq!(changes[0].from, json!(null));
387        assert_eq!(changes[0].to, json!("FRA1"));
388    }
389
390    #[test]
391    fn diff_attrs_field_removed_in_desired_is_ignored() {
392        let existing = make_attrs(&[("name", json!("FRA1")), ("extra", json!(true))]);
393        let desired = make_attrs(&[("name", json!("FRA1"))]);
394        let changes = diff_attrs(&existing, &desired);
395        assert!(changes.is_empty());
396    }
397
398    #[test]
399    fn diff_attrs_multiple_changes() {
400        let existing = make_attrs(&[("a", json!(1)), ("b", json!(2))]);
401        let desired = make_attrs(&[("a", json!(10)), ("b", json!(20))]);
402        let changes = diff_attrs(&existing, &desired);
403        assert_eq!(changes.len(), 2);
404        let fields: Vec<&str> = changes.iter().map(|c| c.field.as_str()).collect();
405        assert!(fields.contains(&"a"));
406        assert!(fields.contains(&"b"));
407    }
408
409    #[test]
410    fn diff_attrs_type_change() {
411        let existing = make_attrs(&[("val", json!("string"))]);
412        let desired = make_attrs(&[("val", json!(42))]);
413        let changes = diff_attrs(&existing, &desired);
414        assert_eq!(changes.len(), 1);
415        assert_eq!(changes[0].from, json!("string"));
416        assert_eq!(changes[0].to, json!(42));
417    }
418
419    // --- plan() ---
420
421    #[test]
422    fn plan_creates_for_new_objects() {
423        let desired = vec![make_object(
424            1,
425            "dcim.site",
426            "fra1",
427            make_attrs(&[("name", json!("FRA1"))]),
428        )];
429        let observed = ObservedState::default();
430        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
431        assert_eq!(result.ops.len(), 1);
432        assert!(matches!(&result.ops[0], Op::Create { uid, type_name, .. }
433            if *uid == Uid::from_u128(1) && type_name.as_str() == "dcim.site"));
434        let summary = result.summary.unwrap();
435        assert_eq!(summary.create, 1);
436        assert_eq!(summary.update, 0);
437        assert_eq!(summary.delete, 0);
438    }
439
440    #[test]
441    fn plan_updates_when_attrs_differ() {
442        let desired = vec![make_object(
443            1,
444            "dcim.site",
445            "fra1",
446            make_attrs(&[("name", json!("FRA2"))]),
447        )];
448        let mut observed = ObservedState::default();
449        observed
450            .insert(ObservedObject {
451                type_name: TypeName::new("dcim.site"),
452                key: make_key("fra1"),
453                attrs: make_attrs(&[("name", json!("FRA1"))]),
454                backend_id: Some(BackendId::Int(100)),
455            })
456            .unwrap();
457        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
458        assert_eq!(result.ops.len(), 1);
459        match &result.ops[0] {
460            Op::Update {
461                changes,
462                backend_id,
463                ..
464            } => {
465                assert_eq!(changes.len(), 1);
466                assert_eq!(changes[0].field, "name");
467                assert_eq!(backend_id, &Some(BackendId::Int(100)));
468            }
469            other => panic!("expected Update, got {:?}", other),
470        }
471    }
472
473    #[test]
474    fn plan_no_op_when_identical() {
475        let desired = vec![make_object(
476            1,
477            "dcim.site",
478            "fra1",
479            make_attrs(&[("name", json!("FRA1"))]),
480        )];
481        let mut observed = ObservedState::default();
482        observed
483            .insert(ObservedObject {
484                type_name: TypeName::new("dcim.site"),
485                key: make_key("fra1"),
486                attrs: make_attrs(&[("name", json!("FRA1"))]),
487                backend_id: Some(BackendId::Int(100)),
488            })
489            .unwrap();
490        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
491        assert!(result.ops.is_empty());
492    }
493
494    #[test]
495    fn plan_deletes_unmatched_when_allowed() {
496        let desired = vec![];
497        let mut observed = ObservedState::default();
498        observed
499            .insert(ObservedObject {
500                type_name: TypeName::new("dcim.site"),
501                key: make_key("fra1"),
502                attrs: make_attrs(&[("name", json!("FRA1"))]),
503                backend_id: Some(BackendId::Int(100)),
504            })
505            .unwrap();
506        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
507        assert_eq!(result.ops.len(), 1);
508        assert!(matches!(
509            &result.ops[0],
510            Op::Delete {
511                backend_id: Some(BackendId::Int(100)),
512                ..
513            }
514        ));
515    }
516
517    #[test]
518    fn plan_no_deletes_when_disallowed() {
519        let desired = vec![];
520        let mut observed = ObservedState::default();
521        observed
522            .insert(ObservedObject {
523                type_name: TypeName::new("dcim.site"),
524                key: make_key("fra1"),
525                attrs: make_attrs(&[("name", json!("FRA1"))]),
526                backend_id: Some(BackendId::Int(100)),
527            })
528            .unwrap();
529        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
530        assert!(result.ops.is_empty());
531    }
532
533    #[test]
534    fn plan_matched_objects_not_deleted() {
535        let desired = vec![make_object(
536            1,
537            "dcim.site",
538            "fra1",
539            make_attrs(&[("name", json!("FRA1"))]),
540        )];
541        let mut observed = ObservedState::default();
542        observed
543            .insert(ObservedObject {
544                type_name: TypeName::new("dcim.site"),
545                key: make_key("fra1"),
546                attrs: make_attrs(&[("name", json!("FRA1"))]),
547                backend_id: Some(BackendId::Int(100)),
548            })
549            .unwrap();
550        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
551        assert!(result.ops.is_empty());
552    }
553
554    #[test]
555    fn plan_mixed_create_update_delete() {
556        let desired = vec![
557            make_object(
558                1,
559                "dcim.site",
560                "fra1",
561                make_attrs(&[("name", json!("FRA1-new"))]),
562            ),
563            make_object(
564                2,
565                "dcim.site",
566                "ams1",
567                make_attrs(&[("name", json!("AMS1"))]),
568            ),
569        ];
570        let mut observed = ObservedState::default();
571        observed
572            .insert(ObservedObject {
573                type_name: TypeName::new("dcim.site"),
574                key: make_key("fra1"),
575                attrs: make_attrs(&[("name", json!("FRA1"))]),
576                backend_id: Some(BackendId::Int(100)),
577            })
578            .unwrap();
579        observed
580            .insert(ObservedObject {
581                type_name: TypeName::new("dcim.site"),
582                key: make_key("lhr1"),
583                attrs: make_attrs(&[("name", json!("LHR1"))]),
584                backend_id: Some(BackendId::Int(200)),
585            })
586            .unwrap();
587        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
588
589        let creates: Vec<_> = result
590            .ops
591            .iter()
592            .filter(|op| matches!(op, Op::Create { .. }))
593            .collect();
594        let updates: Vec<_> = result
595            .ops
596            .iter()
597            .filter(|op| matches!(op, Op::Update { .. }))
598            .collect();
599        let deletes: Vec<_> = result
600            .ops
601            .iter()
602            .filter(|op| matches!(op, Op::Delete { .. }))
603            .collect();
604        assert_eq!(creates.len(), 1);
605        assert_eq!(updates.len(), 1);
606        assert_eq!(deletes.len(), 1);
607
608        let summary = result.summary.unwrap();
609        assert_eq!(summary.create, 1);
610        assert_eq!(summary.update, 1);
611        assert_eq!(summary.delete, 1);
612    }
613
614    #[test]
615    fn plan_uses_state_mapping_for_lookup() {
616        let mut state_data = StateData::default();
617        state_data
618            .mappings
619            .entry(TypeName::new("dcim.site"))
620            .or_default()
621            .insert(Uid::from_u128(1), BackendId::Int(100));
622        let state = StateStore::new(None, state_data);
623
624        let desired = vec![make_object(
625            1,
626            "dcim.site",
627            "fra1",
628            make_attrs(&[("name", json!("FRA2"))]),
629        )];
630        let mut observed = ObservedState::default();
631        observed
632            .insert(ObservedObject {
633                type_name: TypeName::new("dcim.site"),
634                key: make_key("fra1"),
635                attrs: make_attrs(&[("name", json!("FRA1"))]),
636                backend_id: Some(BackendId::Int(100)),
637            })
638            .unwrap();
639        let result = plan(&desired, &observed, &state, &empty_schema(), false);
640        assert_eq!(result.ops.len(), 1);
641        assert!(matches!(&result.ops[0], Op::Update { .. }));
642    }
643
644    // --- sort_ops_for_apply ---
645
646    #[test]
647    fn sort_ops_creates_before_deletes() {
648        let ops = vec![
649            Op::Delete {
650                uid: Uid::from_u128(1),
651                type_name: TypeName::new("dcim.site"),
652                key: make_key("fra1"),
653                backend_id: Some(BackendId::Int(100)),
654            },
655            Op::Create {
656                uid: Uid::from_u128(2),
657                type_name: TypeName::new("dcim.site"),
658                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
659            },
660        ];
661        let sorted = sort_ops_for_apply(&ops, &empty_schema());
662        assert!(matches!(&sorted[0], Op::Create { .. }));
663        assert!(matches!(&sorted[1], Op::Delete { .. }));
664    }
665
666    #[test]
667    fn sort_ops_updates_before_deletes() {
668        let ops = vec![
669            Op::Delete {
670                uid: Uid::from_u128(1),
671                type_name: TypeName::new("dcim.site"),
672                key: make_key("fra1"),
673                backend_id: None,
674            },
675            Op::Update {
676                uid: Uid::from_u128(2),
677                type_name: TypeName::new("dcim.site"),
678                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
679                changes: vec![],
680                backend_id: None,
681            },
682        ];
683        let sorted = sort_ops_for_apply(&ops, &empty_schema());
684        assert!(matches!(&sorted[0], Op::Update { .. }));
685        assert!(matches!(&sorted[1], Op::Delete { .. }));
686    }
687
688    #[test]
689    fn sort_ops_deletes_last() {
690        let ops = vec![
691            Op::Delete {
692                uid: Uid::from_u128(1),
693                type_name: TypeName::new("a.type"),
694                key: make_key("a"),
695                backend_id: None,
696            },
697            Op::Delete {
698                uid: Uid::from_u128(2),
699                type_name: TypeName::new("z.type"),
700                key: make_key("z"),
701                backend_id: None,
702            },
703        ];
704        let sorted = sort_ops_for_apply(&ops, &empty_schema());
705        // both deletes come last, sorted alphabetically by type
706        assert!(
707            matches!(&sorted[0], Op::Delete { type_name, .. } if type_name.as_str() == "a.type")
708        );
709        assert!(
710            matches!(&sorted[1], Op::Delete { type_name, .. } if type_name.as_str() == "z.type")
711        );
712    }
713
714    #[test]
715    fn sort_ops_empty_input() {
716        let sorted = sort_ops_for_apply(&[], &empty_schema());
717        assert!(sorted.is_empty());
718    }
719
720    #[test]
721    fn sort_ops_preserves_create_update_order() {
722        let ops = vec![
723            Op::Update {
724                uid: Uid::from_u128(2),
725                type_name: TypeName::new("dcim.site"),
726                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
727                changes: vec![],
728                backend_id: None,
729            },
730            Op::Create {
731                uid: Uid::from_u128(1),
732                type_name: TypeName::new("dcim.site"),
733                desired: make_object(1, "dcim.site", "aaa1", make_attrs(&[])),
734            },
735        ];
736        let sorted = sort_ops_for_apply(&ops, &empty_schema());
737        assert!(matches!(&sorted[0], Op::Create { .. }));
738        assert!(matches!(&sorted[1], Op::Update { .. }));
739    }
740
741    // --- sort_ops_for_apply: topological ordering by reference (issue #47) ---
742
743    fn field(r#type: FieldType) -> FieldSchema {
744        FieldSchema {
745            r#type,
746            required: false,
747            nullable: true,
748            format: None,
749            pattern: None,
750            description: None,
751        }
752    }
753
754    fn ref_t() -> FieldType {
755        FieldType::Ref {
756            target: "node".to_string(),
757        }
758    }
759
760    fn list_ref_t() -> FieldType {
761        FieldType::ListRef {
762            target: "node".to_string(),
763        }
764    }
765
766    /// schema with a single type `node` keyed by `slug` plus the given fields.
767    fn node_schema(fields: &[(&str, FieldType)]) -> Schema {
768        let mut field_map = BTreeMap::new();
769        for (name, ty) in fields {
770            field_map.insert(name.to_string(), field(ty.clone()));
771        }
772        let mut key = BTreeMap::new();
773        key.insert("slug".to_string(), field(FieldType::Slug));
774        let mut types = BTreeMap::new();
775        types.insert(
776            "node".to_string(),
777            TypeSchema {
778                key,
779                fields: field_map,
780            },
781        );
782        Schema { types }
783    }
784
785    /// a uuid string ref value for the given uid.
786    fn uref(uid: u128) -> serde_json::Value {
787        json!(Uid::from_u128(uid).to_string())
788    }
789
790    fn create_node(uid: u128, slug: &str, attrs: JsonMap) -> Op {
791        Op::Create {
792            uid: Uid::from_u128(uid),
793            type_name: TypeName::new("node"),
794            desired: make_object(uid, "node", slug, attrs),
795        }
796    }
797
798    /// position of the op carrying `uid` in the ordered output.
799    fn order_index(ops: &[Op], uid: u128) -> usize {
800        ops.iter()
801            .position(|op| op.uid().as_u128() == uid)
802            .expect("uid present in ordered ops")
803    }
804
805    #[test]
806    fn toposort_linear_ref_chain() {
807        let schema = node_schema(&[("next", ref_t())]);
808        let ops = vec![
809            create_node(1, "a", make_attrs(&[("next", uref(2))])),
810            create_node(2, "b", make_attrs(&[("next", uref(3))])),
811            create_node(3, "c", make_attrs(&[])),
812        ];
813        let sorted = sort_ops_for_apply(&ops, &schema);
814        // referenced objects are created before the objects referencing them.
815        assert!(order_index(&sorted, 3) < order_index(&sorted, 2));
816        assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
817    }
818
819    #[test]
820    fn toposort_diamond_shared_dep() {
821        let schema = node_schema(&[("parent", ref_t()), ("left", ref_t()), ("right", ref_t())]);
822        let ops = vec![
823            create_node(1, "d", make_attrs(&[("left", uref(2)), ("right", uref(3))])),
824            create_node(2, "b", make_attrs(&[("parent", uref(4))])),
825            create_node(3, "c", make_attrs(&[("parent", uref(4))])),
826            create_node(4, "a", make_attrs(&[])),
827        ];
828        let sorted = sort_ops_for_apply(&ops, &schema);
829        let (a, b, c, d) = (
830            order_index(&sorted, 4),
831            order_index(&sorted, 2),
832            order_index(&sorted, 3),
833            order_index(&sorted, 1),
834        );
835        assert!(a < b && a < c, "shared dep created first");
836        assert!(b < d && c < d, "both branches created before the joiner");
837    }
838
839    #[test]
840    fn toposort_list_ref_fan_out() {
841        let schema = node_schema(&[("members", list_ref_t())]);
842        let ops = vec![
843            create_node(
844                1,
845                "a",
846                make_attrs(&[("members", json!([uref(2), uref(3), uref(4)]))]),
847            ),
848            create_node(2, "b", make_attrs(&[])),
849            create_node(3, "c", make_attrs(&[])),
850            create_node(4, "d", make_attrs(&[])),
851        ];
852        let sorted = sort_ops_for_apply(&ops, &schema);
853        let a = order_index(&sorted, 1);
854        assert!(order_index(&sorted, 2) < a);
855        assert!(order_index(&sorted, 3) < a);
856        assert!(order_index(&sorted, 4) < a);
857    }
858
859    #[test]
860    fn toposort_refs_nested_in_list_and_map() {
861        let schema = node_schema(&[
862            (
863                "group",
864                FieldType::List {
865                    item: Box::new(ref_t()),
866                },
867            ),
868            (
869                "lookup",
870                FieldType::Map {
871                    value: Box::new(ref_t()),
872                },
873            ),
874        ]);
875        let ops = vec![
876            create_node(
877                1,
878                "a",
879                make_attrs(&[
880                    ("group", json!([uref(2)])),
881                    ("lookup", json!({ "k": uref(3) })),
882                ]),
883            ),
884            create_node(2, "b", make_attrs(&[])),
885            create_node(3, "c", make_attrs(&[])),
886        ];
887        let sorted = sort_ops_for_apply(&ops, &schema);
888        let a = order_index(&sorted, 1);
889        assert!(order_index(&sorted, 2) < a, "ref nested in list");
890        assert!(order_index(&sorted, 3) < a, "ref nested in map");
891    }
892
893    #[test]
894    fn toposort_reference_cycle_falls_back_to_stable_order() {
895        let schema = node_schema(&[("next", ref_t())]);
896        let ops = vec![
897            create_node(1, "aaa", make_attrs(&[("next", uref(2))])),
898            create_node(2, "bbb", make_attrs(&[("next", uref(1))])),
899        ];
900        let sorted = sort_ops_for_apply(&ops, &schema);
901        // a cycle still yields a total order without panicking or dropping ops.
902        assert_eq!(sorted.len(), 2);
903        // the cyclic remainder is emitted in stable op_order_key (slug) order.
904        assert_eq!(order_index(&sorted, 1), 0);
905        assert_eq!(order_index(&sorted, 2), 1);
906    }
907
908    #[test]
909    fn reverse_toposort_deletes_child_before_parent() {
910        // `child` is keyed by a reference to `node`, so deleting the parent
911        // node must wait until the referencing child is gone.
912        let mut node_key = BTreeMap::new();
913        node_key.insert("slug".to_string(), field(FieldType::Slug));
914        let mut child_key = BTreeMap::new();
915        child_key.insert("parent".to_string(), field(ref_t()));
916        child_key.insert("slug".to_string(), field(FieldType::Slug));
917        let mut types = BTreeMap::new();
918        types.insert(
919            "node".to_string(),
920            TypeSchema {
921                key: node_key,
922                fields: BTreeMap::new(),
923            },
924        );
925        types.insert(
926            "child".to_string(),
927            TypeSchema {
928                key: child_key,
929                fields: BTreeMap::new(),
930            },
931        );
932        let schema = Schema { types };
933
934        let parent_key = Key::from(BTreeMap::from([("slug".to_string(), json!("p"))]));
935        let child_key_val = Key::from(BTreeMap::from([
936            ("parent".to_string(), uref(1)),
937            ("slug".to_string(), json!("c")),
938        ]));
939        let ops = vec![
940            Op::Delete {
941                uid: Uid::from_u128(1),
942                type_name: TypeName::new("node"),
943                key: parent_key,
944                backend_id: None,
945            },
946            Op::Delete {
947                uid: Uid::from_u128(2),
948                type_name: TypeName::new("child"),
949                key: child_key_val,
950                backend_id: None,
951            },
952        ];
953        let sorted = sort_ops_for_apply(&ops, &schema);
954        assert!(
955            order_index(&sorted, 2) < order_index(&sorted, 1),
956            "child deleted before the parent it references"
957        );
958        assert!(sorted.iter().all(|op| matches!(op, Op::Delete { .. })));
959    }
960
961    #[test]
962    fn toposort_keeps_deletes_after_creates_and_updates() {
963        let schema = node_schema(&[("next", ref_t())]);
964        let ops = vec![
965            Op::Delete {
966                uid: Uid::from_u128(9),
967                type_name: TypeName::new("node"),
968                key: make_key("z"),
969                backend_id: None,
970            },
971            create_node(1, "a", make_attrs(&[("next", uref(2))])),
972            create_node(2, "b", make_attrs(&[])),
973        ];
974        let sorted = sort_ops_for_apply(&ops, &schema);
975        let non_delete_count = sorted
976            .iter()
977            .filter(|op| !matches!(op, Op::Delete { .. }))
978            .count();
979        let delete_pos = sorted
980            .iter()
981            .position(|op| matches!(op, Op::Delete { .. }))
982            .unwrap();
983        // every create/update precedes the single delete.
984        assert_eq!(delete_pos, non_delete_count);
985        // and the create chain is still toposorted within its block.
986        assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
987    }
988}