1use 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
9pub 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
92fn 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
120fn op_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
122 (type_name.as_str().to_string(), key_string(key))
123}
124
125fn 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
139pub 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)), _ => (0u8, op_order_key(op)), }
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 #[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 #[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 #[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 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}