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::{key_string, uid_v5, JsonMap, Key, Object, TypeName};
6use serde_json::Value;
7use std::collections::{BTreeMap, BTreeSet};
8
9/// build a deterministic plan from desired and observed state.
10pub fn plan(
11    desired: &[Object],
12    observed: &ObservedState,
13    state: &StateStore,
14    schema: &alembic_core::Schema,
15    allow_delete: bool,
16) -> Plan {
17    let mut ops = Vec::new();
18    let mut matched = BTreeSet::new();
19    let mut backend_to_uid = BTreeMap::new();
20
21    for (type_name, mapping) in state.all_mappings() {
22        for (uid, backend_id) in mapping {
23            backend_to_uid.insert((backend_id.clone(), type_name.clone()), *uid);
24        }
25    }
26
27    let mut desired_sorted = desired.to_vec();
28    desired_sorted.sort_by_key(|a| op_sort_key(&a.type_name, &a.key));
29
30    for object in desired_sorted.iter() {
31        let observed_object = state
32            .backend_id(object.type_name.clone(), object.uid)
33            .and_then(|id| observed.by_backend_id.get(&(object.type_name.clone(), id)))
34            .or_else(|| {
35                observed
36                    .by_key
37                    .get(&(object.type_name.clone(), key_string(&object.key)))
38            });
39
40        if let Some(obs) = observed_object {
41            let changes = diff_object(obs, object);
42            if !changes.is_empty() {
43                ops.push(Op::Update {
44                    uid: object.uid,
45                    type_name: object.type_name.clone(),
46                    desired: object.clone(),
47                    changes,
48                    backend_id: obs.backend_id.clone(),
49                });
50            }
51            if let Some(backend_id) = &obs.backend_id {
52                matched.insert(backend_id.clone());
53            }
54        } else {
55            ops.push(Op::Create {
56                uid: object.uid,
57                type_name: object.type_name.clone(),
58                desired: object.clone(),
59            });
60        }
61    }
62
63    if allow_delete {
64        for ((type_name, backend_id), obs) in &observed.by_backend_id {
65            if matched.contains(backend_id) {
66                continue;
67            }
68            let uid = backend_to_uid
69                .get(&(backend_id.clone(), type_name.clone()))
70                .copied()
71                .unwrap_or_else(|| uid_v5(type_name.as_str(), &key_string(&obs.key)));
72            ops.push(Op::Delete {
73                uid,
74                type_name: type_name.clone(),
75                key: obs.key.clone(),
76                backend_id: Some(backend_id.clone()),
77            });
78        }
79    }
80
81    ops.sort_by_key(op_order_key);
82
83    let mut plan = Plan {
84        schema: schema.clone(),
85        ops,
86        summary: None,
87    };
88    plan.summary = Some(plan.summary());
89    plan
90}
91
92/// compute field-level diffs for attrs.
93fn diff_attrs(existing: &JsonMap, desired: &JsonMap) -> Vec<FieldChange> {
94    let mut changes = Vec::new();
95    let keys: BTreeSet<String> = existing.keys().chain(desired.keys()).cloned().collect();
96
97    for key in keys.iter() {
98        let from = existing.get(key).cloned().unwrap_or(Value::Null);
99        let desired_has = desired.contains_key(key);
100        if !desired_has {
101            continue;
102        }
103        let to = desired.get(key).cloned().unwrap_or(Value::Null);
104        if from != to {
105            changes.push(FieldChange {
106                field: key.clone(),
107                from,
108                to,
109            });
110        }
111    }
112
113    changes
114}
115
116fn diff_object(existing: &crate::types::ObservedObject, desired: &Object) -> Vec<FieldChange> {
117    diff_attrs(&existing.attrs, &desired.attrs)
118}
119
120/// stable sort key for desired objects.
121fn op_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
122    (type_name.as_str().to_string(), key_string(key))
123}
124
125/// stable sort key for plan operations.
126fn op_order_key(op: &Op) -> (String, u8, String) {
127    let (type_name, key, weight) = match op {
128        Op::Create {
129            type_name, desired, ..
130        } => (type_name.clone(), key_string(&desired.key), 0u8),
131        Op::Update {
132            type_name, desired, ..
133        } => (type_name.clone(), key_string(&desired.key), 1u8),
134        Op::Delete { type_name, key, .. } => (type_name.clone(), key_string(key), 2u8),
135    };
136    (type_name.as_str().to_string(), weight, key)
137}
138
139/// order operations for apply (creates/updates first, deletes last).
140pub fn sort_ops_for_apply(ops: &[Op]) -> Vec<Op> {
141    let mut result: Vec<Op> = ops.to_vec();
142    result.sort_by_key(|op| {
143        match op {
144            Op::Delete { .. } => (1u8, op_order_key(op)), // deletes last
145            _ => (0u8, op_order_key(op)),                 // creates/updates first
146        }
147    });
148    result
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::state::{StateData, StateStore};
155    use crate::types::{BackendId, ObservedObject, ObservedState};
156    use alembic_core::{JsonMap, Key, Object, Schema, TypeName, Uid};
157    use serde_json::json;
158    use std::collections::BTreeMap;
159
160    fn make_key(slug: &str) -> Key {
161        let mut k = BTreeMap::new();
162        k.insert("slug".to_string(), json!(slug));
163        Key::from(k)
164    }
165
166    fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
167        let mut m = BTreeMap::new();
168        for (k, v) in pairs {
169            m.insert(k.to_string(), v.clone());
170        }
171        JsonMap::from(m)
172    }
173
174    fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
175        Object::new(
176            Uid::from_u128(uid),
177            TypeName::new(type_name),
178            make_key(slug),
179            attrs,
180        )
181        .unwrap()
182    }
183
184    fn empty_schema() -> Schema {
185        Schema {
186            types: BTreeMap::new(),
187        }
188    }
189
190    fn empty_state() -> StateStore {
191        StateStore::new(None, StateData::default())
192    }
193
194    // --- diff_attrs ---
195
196    #[test]
197    fn diff_attrs_identical_maps() {
198        let attrs = make_attrs(&[("name", json!("FRA1"))]);
199        let changes = diff_attrs(&attrs, &attrs);
200        assert!(changes.is_empty());
201    }
202
203    #[test]
204    fn diff_attrs_field_changed() {
205        let existing = make_attrs(&[("name", json!("FRA1"))]);
206        let desired = make_attrs(&[("name", json!("FRA2"))]);
207        let changes = diff_attrs(&existing, &desired);
208        assert_eq!(changes.len(), 1);
209        assert_eq!(changes[0].field, "name");
210        assert_eq!(changes[0].from, json!("FRA1"));
211        assert_eq!(changes[0].to, json!("FRA2"));
212    }
213
214    #[test]
215    fn diff_attrs_field_added() {
216        let existing = make_attrs(&[]);
217        let desired = make_attrs(&[("name", json!("FRA1"))]);
218        let changes = diff_attrs(&existing, &desired);
219        assert_eq!(changes.len(), 1);
220        assert_eq!(changes[0].field, "name");
221        assert_eq!(changes[0].from, json!(null));
222        assert_eq!(changes[0].to, json!("FRA1"));
223    }
224
225    #[test]
226    fn diff_attrs_field_removed_in_desired_is_ignored() {
227        let existing = make_attrs(&[("name", json!("FRA1")), ("extra", json!(true))]);
228        let desired = make_attrs(&[("name", json!("FRA1"))]);
229        let changes = diff_attrs(&existing, &desired);
230        assert!(changes.is_empty());
231    }
232
233    #[test]
234    fn diff_attrs_multiple_changes() {
235        let existing = make_attrs(&[("a", json!(1)), ("b", json!(2))]);
236        let desired = make_attrs(&[("a", json!(10)), ("b", json!(20))]);
237        let changes = diff_attrs(&existing, &desired);
238        assert_eq!(changes.len(), 2);
239        let fields: Vec<&str> = changes.iter().map(|c| c.field.as_str()).collect();
240        assert!(fields.contains(&"a"));
241        assert!(fields.contains(&"b"));
242    }
243
244    #[test]
245    fn diff_attrs_type_change() {
246        let existing = make_attrs(&[("val", json!("string"))]);
247        let desired = make_attrs(&[("val", json!(42))]);
248        let changes = diff_attrs(&existing, &desired);
249        assert_eq!(changes.len(), 1);
250        assert_eq!(changes[0].from, json!("string"));
251        assert_eq!(changes[0].to, json!(42));
252    }
253
254    // --- plan() ---
255
256    #[test]
257    fn plan_creates_for_new_objects() {
258        let desired = vec![make_object(
259            1,
260            "dcim.site",
261            "fra1",
262            make_attrs(&[("name", json!("FRA1"))]),
263        )];
264        let observed = ObservedState::default();
265        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
266        assert_eq!(result.ops.len(), 1);
267        assert!(matches!(&result.ops[0], Op::Create { uid, type_name, .. }
268            if *uid == Uid::from_u128(1) && type_name.as_str() == "dcim.site"));
269        let summary = result.summary.unwrap();
270        assert_eq!(summary.create, 1);
271        assert_eq!(summary.update, 0);
272        assert_eq!(summary.delete, 0);
273    }
274
275    #[test]
276    fn plan_updates_when_attrs_differ() {
277        let desired = vec![make_object(
278            1,
279            "dcim.site",
280            "fra1",
281            make_attrs(&[("name", json!("FRA2"))]),
282        )];
283        let mut observed = ObservedState::default();
284        observed.insert(ObservedObject {
285            type_name: TypeName::new("dcim.site"),
286            key: make_key("fra1"),
287            attrs: make_attrs(&[("name", json!("FRA1"))]),
288            backend_id: Some(BackendId::Int(100)),
289        });
290        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
291        assert_eq!(result.ops.len(), 1);
292        match &result.ops[0] {
293            Op::Update {
294                changes,
295                backend_id,
296                ..
297            } => {
298                assert_eq!(changes.len(), 1);
299                assert_eq!(changes[0].field, "name");
300                assert_eq!(backend_id, &Some(BackendId::Int(100)));
301            }
302            other => panic!("expected Update, got {:?}", other),
303        }
304    }
305
306    #[test]
307    fn plan_no_op_when_identical() {
308        let desired = vec![make_object(
309            1,
310            "dcim.site",
311            "fra1",
312            make_attrs(&[("name", json!("FRA1"))]),
313        )];
314        let mut observed = ObservedState::default();
315        observed.insert(ObservedObject {
316            type_name: TypeName::new("dcim.site"),
317            key: make_key("fra1"),
318            attrs: make_attrs(&[("name", json!("FRA1"))]),
319            backend_id: Some(BackendId::Int(100)),
320        });
321        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
322        assert!(result.ops.is_empty());
323    }
324
325    #[test]
326    fn plan_deletes_unmatched_when_allowed() {
327        let desired = vec![];
328        let mut observed = ObservedState::default();
329        observed.insert(ObservedObject {
330            type_name: TypeName::new("dcim.site"),
331            key: make_key("fra1"),
332            attrs: make_attrs(&[("name", json!("FRA1"))]),
333            backend_id: Some(BackendId::Int(100)),
334        });
335        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
336        assert_eq!(result.ops.len(), 1);
337        assert!(matches!(
338            &result.ops[0],
339            Op::Delete {
340                backend_id: Some(BackendId::Int(100)),
341                ..
342            }
343        ));
344    }
345
346    #[test]
347    fn plan_no_deletes_when_disallowed() {
348        let desired = vec![];
349        let mut observed = ObservedState::default();
350        observed.insert(ObservedObject {
351            type_name: TypeName::new("dcim.site"),
352            key: make_key("fra1"),
353            attrs: make_attrs(&[("name", json!("FRA1"))]),
354            backend_id: Some(BackendId::Int(100)),
355        });
356        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
357        assert!(result.ops.is_empty());
358    }
359
360    #[test]
361    fn plan_matched_objects_not_deleted() {
362        let desired = vec![make_object(
363            1,
364            "dcim.site",
365            "fra1",
366            make_attrs(&[("name", json!("FRA1"))]),
367        )];
368        let mut observed = ObservedState::default();
369        observed.insert(ObservedObject {
370            type_name: TypeName::new("dcim.site"),
371            key: make_key("fra1"),
372            attrs: make_attrs(&[("name", json!("FRA1"))]),
373            backend_id: Some(BackendId::Int(100)),
374        });
375        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
376        assert!(result.ops.is_empty());
377    }
378
379    #[test]
380    fn plan_mixed_create_update_delete() {
381        let desired = vec![
382            make_object(
383                1,
384                "dcim.site",
385                "fra1",
386                make_attrs(&[("name", json!("FRA1-new"))]),
387            ),
388            make_object(
389                2,
390                "dcim.site",
391                "ams1",
392                make_attrs(&[("name", json!("AMS1"))]),
393            ),
394        ];
395        let mut observed = ObservedState::default();
396        observed.insert(ObservedObject {
397            type_name: TypeName::new("dcim.site"),
398            key: make_key("fra1"),
399            attrs: make_attrs(&[("name", json!("FRA1"))]),
400            backend_id: Some(BackendId::Int(100)),
401        });
402        observed.insert(ObservedObject {
403            type_name: TypeName::new("dcim.site"),
404            key: make_key("lhr1"),
405            attrs: make_attrs(&[("name", json!("LHR1"))]),
406            backend_id: Some(BackendId::Int(200)),
407        });
408        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
409
410        let creates: Vec<_> = result
411            .ops
412            .iter()
413            .filter(|op| matches!(op, Op::Create { .. }))
414            .collect();
415        let updates: Vec<_> = result
416            .ops
417            .iter()
418            .filter(|op| matches!(op, Op::Update { .. }))
419            .collect();
420        let deletes: Vec<_> = result
421            .ops
422            .iter()
423            .filter(|op| matches!(op, Op::Delete { .. }))
424            .collect();
425        assert_eq!(creates.len(), 1);
426        assert_eq!(updates.len(), 1);
427        assert_eq!(deletes.len(), 1);
428
429        let summary = result.summary.unwrap();
430        assert_eq!(summary.create, 1);
431        assert_eq!(summary.update, 1);
432        assert_eq!(summary.delete, 1);
433    }
434
435    #[test]
436    fn plan_uses_state_mapping_for_lookup() {
437        let mut state_data = StateData::default();
438        state_data
439            .mappings
440            .entry(TypeName::new("dcim.site"))
441            .or_default()
442            .insert(Uid::from_u128(1), BackendId::Int(100));
443        let state = StateStore::new(None, state_data);
444
445        let desired = vec![make_object(
446            1,
447            "dcim.site",
448            "fra1",
449            make_attrs(&[("name", json!("FRA2"))]),
450        )];
451        let mut observed = ObservedState::default();
452        observed.insert(ObservedObject {
453            type_name: TypeName::new("dcim.site"),
454            key: make_key("fra1"),
455            attrs: make_attrs(&[("name", json!("FRA1"))]),
456            backend_id: Some(BackendId::Int(100)),
457        });
458        let result = plan(&desired, &observed, &state, &empty_schema(), false);
459        assert_eq!(result.ops.len(), 1);
460        assert!(matches!(&result.ops[0], Op::Update { .. }));
461    }
462
463    // --- sort_ops_for_apply ---
464
465    #[test]
466    fn sort_ops_creates_before_deletes() {
467        let ops = vec![
468            Op::Delete {
469                uid: Uid::from_u128(1),
470                type_name: TypeName::new("dcim.site"),
471                key: make_key("fra1"),
472                backend_id: Some(BackendId::Int(100)),
473            },
474            Op::Create {
475                uid: Uid::from_u128(2),
476                type_name: TypeName::new("dcim.site"),
477                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
478            },
479        ];
480        let sorted = sort_ops_for_apply(&ops);
481        assert!(matches!(&sorted[0], Op::Create { .. }));
482        assert!(matches!(&sorted[1], Op::Delete { .. }));
483    }
484
485    #[test]
486    fn sort_ops_updates_before_deletes() {
487        let ops = vec![
488            Op::Delete {
489                uid: Uid::from_u128(1),
490                type_name: TypeName::new("dcim.site"),
491                key: make_key("fra1"),
492                backend_id: None,
493            },
494            Op::Update {
495                uid: Uid::from_u128(2),
496                type_name: TypeName::new("dcim.site"),
497                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
498                changes: vec![],
499                backend_id: None,
500            },
501        ];
502        let sorted = sort_ops_for_apply(&ops);
503        assert!(matches!(&sorted[0], Op::Update { .. }));
504        assert!(matches!(&sorted[1], Op::Delete { .. }));
505    }
506
507    #[test]
508    fn sort_ops_deletes_last() {
509        let ops = vec![
510            Op::Delete {
511                uid: Uid::from_u128(1),
512                type_name: TypeName::new("a.type"),
513                key: make_key("a"),
514                backend_id: None,
515            },
516            Op::Delete {
517                uid: Uid::from_u128(2),
518                type_name: TypeName::new("z.type"),
519                key: make_key("z"),
520                backend_id: None,
521            },
522        ];
523        let sorted = sort_ops_for_apply(&ops);
524        // Both deletes come last, sorted alphabetically by type
525        assert!(
526            matches!(&sorted[0], Op::Delete { type_name, .. } if type_name.as_str() == "a.type")
527        );
528        assert!(
529            matches!(&sorted[1], Op::Delete { type_name, .. } if type_name.as_str() == "z.type")
530        );
531    }
532
533    #[test]
534    fn sort_ops_empty_input() {
535        let sorted = sort_ops_for_apply(&[]);
536        assert!(sorted.is_empty());
537    }
538
539    #[test]
540    fn sort_ops_preserves_create_update_order() {
541        let ops = vec![
542            Op::Update {
543                uid: Uid::from_u128(2),
544                type_name: TypeName::new("dcim.site"),
545                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
546                changes: vec![],
547                backend_id: None,
548            },
549            Op::Create {
550                uid: Uid::from_u128(1),
551                type_name: TypeName::new("dcim.site"),
552                desired: make_object(1, "dcim.site", "aaa1", make_attrs(&[])),
553            },
554        ];
555        let sorted = sort_ops_for_apply(&ops);
556        assert!(matches!(&sorted[0], Op::Create { .. }));
557        assert!(matches!(&sorted[1], Op::Update { .. }));
558    }
559}