1use crate::{AdapterApplyError, BackendId, StateStore};
2use alembic_core::{FieldType, JsonMap, Key, TypeSchema, Uid};
3use anyhow::{anyhow, Result};
4use serde_json::{Map, Value};
5use std::collections::BTreeMap;
6
7pub fn build_key_from_schema(type_schema: &TypeSchema, attrs: &JsonMap) -> Result<Key> {
8 let mut map = BTreeMap::new();
9 for field in type_schema.key.keys() {
10 let Some(value) = attrs.get(field) else {
11 return Err(anyhow!("missing key field {field}"));
12 };
13 map.insert(field.clone(), value.clone());
14 }
15 Ok(Key::from(map))
16}
17
18pub fn build_request_body<Id, F>(
19 type_schema: &TypeSchema,
20 attrs: &JsonMap,
21 resolved: &BTreeMap<Uid, Id>,
22 encode_ref: F,
23) -> Result<Value>
24where
25 F: Fn(&Id) -> Value + Copy,
26{
27 let mut map = Map::new();
28 for (key, value) in attrs.iter() {
29 let field_schema = type_schema
30 .fields
31 .get(key)
32 .ok_or_else(|| anyhow!("missing schema for field {key}"))?;
33 if value.is_null() {
34 map.insert(key.clone(), Value::Null);
35 continue;
36 }
37 map.insert(
38 key.clone(),
39 resolve_value_for_type(&field_schema.r#type, value.clone(), resolved, encode_ref)?,
40 );
41 }
42 Ok(Value::Object(map))
43}
44
45pub fn resolve_value_for_type<Id, F>(
46 field_type: &FieldType,
47 value: Value,
48 resolved: &BTreeMap<Uid, Id>,
49 encode_ref: F,
50) -> Result<Value>
51where
52 F: Fn(&Id) -> Value + Copy,
53{
54 match field_type {
55 FieldType::Ref { .. } => resolve_ref_value(value, resolved, encode_ref),
56 FieldType::ListRef { .. } => resolve_list_ref_value(value, resolved, encode_ref),
57 FieldType::List { item } => resolve_list_value(item, value, resolved, encode_ref),
58 FieldType::Map { value: inner } => resolve_map_value(inner, value, resolved, encode_ref),
59 _ => Ok(value),
60 }
61}
62
63pub fn query_filters_from_key<Id>(
64 type_schema: &TypeSchema,
65 key: &Key,
66 resolved: &BTreeMap<Uid, Id>,
67) -> Result<Vec<(String, String)>>
68where
69 Id: ToString,
70{
71 let mut filters = Vec::new();
72 for (field, value) in key.iter() {
73 let field_schema = type_schema
74 .key
75 .get(field)
76 .ok_or_else(|| anyhow!("missing schema for key field {field}"))?;
77 add_query_filters(&mut filters, field, &field_schema.r#type, value, resolved)?;
78 }
79 Ok(filters)
80}
81
82fn resolve_ref_value<Id, F>(
83 value: Value,
84 resolved: &BTreeMap<Uid, Id>,
85 encode_ref: F,
86) -> Result<Value>
87where
88 F: Fn(&Id) -> Value + Copy,
89{
90 let Value::String(raw) = value else {
91 return Err(anyhow!("ref value must be a uuid string"));
92 };
93 let uid = Uid::parse_str(&raw).map_err(|_| anyhow!("ref value is not a uuid: {raw}"))?;
94 let id = resolved
95 .get(&uid)
96 .ok_or(AdapterApplyError::MissingRef { uid })?;
97 Ok(encode_ref(id))
98}
99
100fn resolve_list_ref_value<Id, F>(
101 value: Value,
102 resolved: &BTreeMap<Uid, Id>,
103 encode_ref: F,
104) -> Result<Value>
105where
106 F: Fn(&Id) -> Value + Copy,
107{
108 let Value::Array(items) = value else {
109 return Err(anyhow!("list_ref value must be an array"));
110 };
111 let mut out = Vec::with_capacity(items.len());
112 for item in items {
113 out.push(resolve_ref_value(item, resolved, encode_ref)?);
114 }
115 Ok(Value::Array(out))
116}
117
118fn resolve_list_value<Id, F>(
119 item_type: &FieldType,
120 value: Value,
121 resolved: &BTreeMap<Uid, Id>,
122 encode_ref: F,
123) -> Result<Value>
124where
125 F: Fn(&Id) -> Value + Copy,
126{
127 let Value::Array(items) = value else {
128 return Err(anyhow!("list value must be an array"));
129 };
130 let mut out = Vec::with_capacity(items.len());
131 for item in items {
132 out.push(resolve_value_for_type(
133 item_type, item, resolved, encode_ref,
134 )?);
135 }
136 Ok(Value::Array(out))
137}
138
139fn resolve_map_value<Id, F>(
140 value_type: &FieldType,
141 value: Value,
142 resolved: &BTreeMap<Uid, Id>,
143 encode_ref: F,
144) -> Result<Value>
145where
146 F: Fn(&Id) -> Value + Copy,
147{
148 let Value::Object(map) = value else {
149 return Err(anyhow!("map value must be an object"));
150 };
151 let mut out = Map::new();
152 for (key, value) in map {
153 out.insert(
154 key,
155 resolve_value_for_type(value_type, value, resolved, encode_ref)?,
156 );
157 }
158 Ok(Value::Object(out))
159}
160
161fn add_query_filters<Id>(
162 filters: &mut Vec<(String, String)>,
163 field: &str,
164 field_type: &FieldType,
165 value: &Value,
166 resolved: &BTreeMap<Uid, Id>,
167) -> Result<()>
168where
169 Id: ToString,
170{
171 match field_type {
172 FieldType::Ref { .. } => {
173 let id = resolve_query_ref(value, resolved)?;
174 filters.push((field.to_string(), id));
175 Ok(())
176 }
177 FieldType::ListRef { .. } => {
178 let Value::Array(items) = value else {
179 return Err(anyhow!("key field {field} must be an array"));
180 };
181 for item in items {
182 let id = resolve_query_ref(item, resolved)?;
183 filters.push((field.to_string(), id));
184 }
185 Ok(())
186 }
187 _ => {
188 let scalar = value_to_query_value(value)?;
189 filters.push((field.to_string(), scalar));
190 Ok(())
191 }
192 }
193}
194
195fn resolve_query_ref<Id>(value: &Value, resolved: &BTreeMap<Uid, Id>) -> Result<String>
196where
197 Id: ToString,
198{
199 let Value::String(raw) = value else {
200 return Err(anyhow!("ref value must be a uuid string"));
201 };
202 let uid = Uid::parse_str(raw).map_err(|_| anyhow!("ref value is not a uuid: {raw}"))?;
203 let id = resolved
204 .get(&uid)
205 .ok_or(AdapterApplyError::MissingRef { uid })?;
206 Ok(id.to_string())
207}
208
209fn value_to_query_value(value: &Value) -> Result<String> {
210 match value {
211 Value::String(raw) => Ok(raw.clone()),
212 Value::Number(num) => Ok(num.to_string()),
213 Value::Bool(value) => Ok(value.to_string()),
214 Value::Null => Err(anyhow!("key value is null")),
215 Value::Array(_) | Value::Object(_) => Err(anyhow!("key value must be scalar")),
216 }
217}
218
219pub fn state_mappings_by_id<I: Ord>(
222 state: &StateStore,
223 extract: impl Fn(&BackendId) -> Option<I>,
224) -> BTreeMap<String, BTreeMap<I, Uid>> {
225 let mut by_type = BTreeMap::new();
226 for (type_name, mapping) in state.all_mappings() {
227 let mut id_to_uid = BTreeMap::new();
228 for (uid, backend_id) in mapping {
229 if let Some(id) = extract(backend_id) {
230 id_to_uid.insert(id, *uid);
231 }
232 }
233 by_type.insert(type_name.as_str().to_string(), id_to_uid);
234 }
235 by_type
236}
237
238pub fn resolved_ids_from_state<I>(
241 state: &StateStore,
242 extract: impl Fn(&BackendId) -> Option<I>,
243) -> BTreeMap<Uid, I> {
244 let mut resolved = BTreeMap::new();
245 for mapping in state.all_mappings().values() {
246 for (uid, backend_id) in mapping {
247 if let Some(id) = extract(backend_id) {
248 resolved.insert(*uid, id);
249 }
250 }
251 }
252 resolved
253}
254
255#[derive(Debug, Default, Clone)]
257pub struct StateMappings {
258 by_type: BTreeMap<String, BTreeMap<BackendId, Uid>>,
259}
260
261impl StateMappings {
262 pub fn uid_for(&self, type_name: &str, backend_id: &BackendId) -> Option<Uid> {
264 self.by_type
265 .get(type_name)
266 .and_then(|mapping| mapping.get(backend_id).copied())
267 }
268
269 pub fn insert(&mut self, type_name: &str, backend_id: BackendId, uid: Uid) {
271 self.by_type
272 .entry(type_name.to_string())
273 .or_default()
274 .insert(backend_id, uid);
275 }
276
277 pub fn from_state(state: &StateStore) -> Self {
279 StateMappings {
280 by_type: state_mappings_by_id(state, |b| Some(b.clone())),
281 }
282 }
283}
284
285pub fn resolved_ids_identity(state: &StateStore) -> BTreeMap<Uid, BackendId> {
288 resolved_ids_from_state(state, |b| Some(b.clone()))
289}
290
291pub fn normalize_attrs_refs(
294 attrs: &JsonMap,
295 type_schema: &TypeSchema,
296 mappings: &StateMappings,
297) -> JsonMap {
298 let mut normalized = attrs.clone();
299 for (field, schema) in &type_schema.fields {
300 if let Some(value) = attrs.get(field) {
301 normalized.insert(
302 field.clone(),
303 normalize_value_for_type(&schema.r#type, value.clone(), mappings),
304 );
305 }
306 }
307 normalized
308}
309
310fn normalize_value_for_type(
313 field_type: &FieldType,
314 value: Value,
315 mappings: &StateMappings,
316) -> Value {
317 match field_type {
318 FieldType::Ref { target } => normalize_ref_value(value, target, mappings),
319 FieldType::ListRef { target } => match value {
320 Value::Array(items) => Value::Array(
321 items
322 .into_iter()
323 .map(|item| normalize_ref_value(item, target, mappings))
324 .collect(),
325 ),
326 other => other,
327 },
328 FieldType::List { item } => match value {
329 Value::Array(items) => Value::Array(
330 items
331 .into_iter()
332 .map(|elem| normalize_value_for_type(item, elem, mappings))
333 .collect(),
334 ),
335 other => other,
336 },
337 FieldType::Map { value: inner } => match value {
338 Value::Object(obj) => Value::Object(
339 obj.into_iter()
340 .map(|(k, v)| (k, normalize_value_for_type(inner, v, mappings)))
341 .collect(),
342 ),
343 other => other,
344 },
345 _ => value,
346 }
347}
348
349fn normalize_ref_value(value: Value, target: &str, mappings: &StateMappings) -> Value {
352 if value.is_null() {
353 return value;
354 }
355 let backend_id = match backend_id_from_value(&value) {
356 Some(id) => id,
357 None => return value,
358 };
359 mappings
360 .uid_for(target, &backend_id)
361 .map(|uid| Value::String(uid.to_string()))
362 .unwrap_or(value)
363}
364
365pub fn backend_id_from_value(value: &Value) -> Option<BackendId> {
368 match value {
369 Value::Number(n) => n.as_u64().map(BackendId::Int).or_else(|| {
370 n.as_i64()
371 .and_then(|v| u64::try_from(v).ok())
372 .map(BackendId::Int)
373 }),
374 Value::String(s) => Some(BackendId::String(s.clone())),
375 Value::Object(map) => map.get("id").and_then(backend_id_from_value),
376 _ => None,
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use alembic_core::{FieldSchema, FieldType, JsonMap, Key, TypeSchema};
384 use serde_json::json;
385 use uuid::Uuid;
386
387 fn field_schema(r#type: FieldType) -> FieldSchema {
388 FieldSchema {
389 r#type,
390 required: false,
391 nullable: false,
392 format: None,
393 pattern: None,
394 description: None,
395 }
396 }
397
398 fn simple_type_schema() -> TypeSchema {
399 TypeSchema {
400 key: BTreeMap::from([("slug".to_string(), field_schema(FieldType::Slug))]),
401 fields: BTreeMap::from([
402 ("name".to_string(), field_schema(FieldType::String)),
403 ("count".to_string(), field_schema(FieldType::Int)),
404 ]),
405 }
406 }
407
408 fn attrs(pairs: Vec<(&str, Value)>) -> JsonMap {
409 JsonMap::from(
410 pairs
411 .into_iter()
412 .map(|(k, v)| (k.to_string(), v))
413 .collect::<BTreeMap<_, _>>(),
414 )
415 }
416
417 fn empty_resolved() -> BTreeMap<Uid, i64> {
418 BTreeMap::new()
419 }
420
421 fn encode_ref(id: &i64) -> Value {
422 json!(id)
423 }
424
425 #[test]
428 fn build_key_from_schema_extracts_key_fields() {
429 let schema = simple_type_schema();
430 let a = attrs(vec![("slug", json!("fra1")), ("name", json!("FRA1"))]);
431 let key = build_key_from_schema(&schema, &a).unwrap();
432 assert_eq!(key.get("slug"), Some(&json!("fra1")));
433 assert_eq!(key.len(), 1);
434 }
435
436 #[test]
437 fn build_key_from_schema_composite_key() {
438 let schema = TypeSchema {
439 key: BTreeMap::from([
440 ("site".to_string(), field_schema(FieldType::String)),
441 ("name".to_string(), field_schema(FieldType::String)),
442 ]),
443 fields: BTreeMap::new(),
444 };
445 let a = attrs(vec![("site", json!("fra1")), ("name", json!("eth0"))]);
446 let key = build_key_from_schema(&schema, &a).unwrap();
447 assert_eq!(key.len(), 2);
448 assert_eq!(key.get("site"), Some(&json!("fra1")));
449 assert_eq!(key.get("name"), Some(&json!("eth0")));
450 }
451
452 #[test]
453 fn build_key_from_schema_missing_field_errors() {
454 let schema = simple_type_schema();
455 let a = attrs(vec![("name", json!("FRA1"))]);
456 let err = build_key_from_schema(&schema, &a).unwrap_err();
457 assert!(err.to_string().contains("missing key field slug"));
458 }
459
460 #[test]
463 fn build_request_body_scalar_fields() {
464 let schema = simple_type_schema();
465 let a = attrs(vec![("name", json!("FRA1")), ("count", json!(42))]);
466 let body = build_request_body(&schema, &a, &empty_resolved(), encode_ref).unwrap();
467 let obj = body.as_object().unwrap();
468 assert_eq!(obj.get("name"), Some(&json!("FRA1")));
469 assert_eq!(obj.get("count"), Some(&json!(42)));
470 }
471
472 #[test]
473 fn build_request_body_null_value_passes_through() {
474 let schema = simple_type_schema();
475 let a = attrs(vec![("name", Value::Null)]);
476 let body = build_request_body(&schema, &a, &empty_resolved(), encode_ref).unwrap();
477 assert_eq!(body.as_object().unwrap().get("name"), Some(&Value::Null));
478 }
479
480 #[test]
481 fn build_request_body_resolves_ref() {
482 let uid = Uuid::from_u128(1);
483 let schema = TypeSchema {
484 key: BTreeMap::new(),
485 fields: BTreeMap::from([(
486 "site".to_string(),
487 field_schema(FieldType::Ref {
488 target: "dcim.site".to_string(),
489 }),
490 )]),
491 };
492 let a = attrs(vec![("site", json!(uid.to_string()))]);
493 let mut resolved = BTreeMap::new();
494 resolved.insert(uid, 99_i64);
495 let body = build_request_body(&schema, &a, &resolved, encode_ref).unwrap();
496 assert_eq!(body.as_object().unwrap().get("site"), Some(&json!(99)));
497 }
498
499 #[test]
500 fn build_request_body_missing_schema_errors() {
501 let schema = simple_type_schema();
502 let a = attrs(vec![("nonexistent", json!("x"))]);
503 let err = build_request_body(&schema, &a, &empty_resolved(), encode_ref).unwrap_err();
504 assert!(err.to_string().contains("missing schema for field"));
505 }
506
507 #[test]
510 fn resolve_value_scalar_passthrough() {
511 let val = json!("hello");
512 let result = resolve_value_for_type(
513 &FieldType::String,
514 val.clone(),
515 &empty_resolved(),
516 encode_ref,
517 )
518 .unwrap();
519 assert_eq!(result, val);
520 }
521
522 #[test]
523 fn resolve_value_ref() {
524 let uid = Uuid::from_u128(5);
525 let mut resolved = BTreeMap::new();
526 resolved.insert(uid, 42_i64);
527 let result = resolve_value_for_type(
528 &FieldType::Ref {
529 target: "t".to_string(),
530 },
531 json!(uid.to_string()),
532 &resolved,
533 encode_ref,
534 )
535 .unwrap();
536 assert_eq!(result, json!(42));
537 }
538
539 #[test]
540 fn resolve_value_ref_missing_uid_errors() {
541 let uid = Uuid::from_u128(99);
542 let err = resolve_value_for_type(
543 &FieldType::Ref {
544 target: "t".to_string(),
545 },
546 json!(uid.to_string()),
547 &empty_resolved(),
548 encode_ref,
549 )
550 .unwrap_err();
551 assert!(err.to_string().contains("missing referenced uid"));
552 }
553
554 #[test]
555 fn resolve_value_ref_non_string_errors() {
556 let err = resolve_value_for_type(
557 &FieldType::Ref {
558 target: "t".to_string(),
559 },
560 json!(123),
561 &empty_resolved(),
562 encode_ref,
563 )
564 .unwrap_err();
565 assert!(err.to_string().contains("ref value must be a uuid string"));
566 }
567
568 #[test]
569 fn resolve_value_ref_invalid_uuid_errors() {
570 let err = resolve_value_for_type(
571 &FieldType::Ref {
572 target: "t".to_string(),
573 },
574 json!("not-a-uuid"),
575 &empty_resolved(),
576 encode_ref,
577 )
578 .unwrap_err();
579 assert!(err.to_string().contains("ref value is not a uuid"));
580 }
581
582 #[test]
583 fn resolve_value_list_ref() {
584 let uid1 = Uuid::from_u128(1);
585 let uid2 = Uuid::from_u128(2);
586 let mut resolved = BTreeMap::new();
587 resolved.insert(uid1, 10_i64);
588 resolved.insert(uid2, 20_i64);
589 let result = resolve_value_for_type(
590 &FieldType::ListRef {
591 target: "t".to_string(),
592 },
593 json!([uid1.to_string(), uid2.to_string()]),
594 &resolved,
595 encode_ref,
596 )
597 .unwrap();
598 assert_eq!(result, json!([10, 20]));
599 }
600
601 #[test]
602 fn resolve_value_list_ref_non_array_errors() {
603 let err = resolve_value_for_type(
604 &FieldType::ListRef {
605 target: "t".to_string(),
606 },
607 json!("not-array"),
608 &empty_resolved(),
609 encode_ref,
610 )
611 .unwrap_err();
612 assert!(err.to_string().contains("list_ref value must be an array"));
613 }
614
615 #[test]
616 fn resolve_value_list_scalars() {
617 let result = resolve_value_for_type(
618 &FieldType::List {
619 item: Box::new(FieldType::String),
620 },
621 json!(["a", "b"]),
622 &empty_resolved(),
623 encode_ref,
624 )
625 .unwrap();
626 assert_eq!(result, json!(["a", "b"]));
627 }
628
629 #[test]
630 fn resolve_value_list_of_refs() {
631 let uid = Uuid::from_u128(3);
632 let mut resolved = BTreeMap::new();
633 resolved.insert(uid, 7_i64);
634 let result = resolve_value_for_type(
635 &FieldType::List {
636 item: Box::new(FieldType::Ref {
637 target: "t".to_string(),
638 }),
639 },
640 json!([uid.to_string()]),
641 &resolved,
642 encode_ref,
643 )
644 .unwrap();
645 assert_eq!(result, json!([7]));
646 }
647
648 #[test]
649 fn resolve_value_list_non_array_errors() {
650 let err = resolve_value_for_type(
651 &FieldType::List {
652 item: Box::new(FieldType::String),
653 },
654 json!("not-array"),
655 &empty_resolved(),
656 encode_ref,
657 )
658 .unwrap_err();
659 assert!(err.to_string().contains("list value must be an array"));
660 }
661
662 #[test]
663 fn resolve_value_map_scalars() {
664 let result = resolve_value_for_type(
665 &FieldType::Map {
666 value: Box::new(FieldType::Int),
667 },
668 json!({"a": 1, "b": 2}),
669 &empty_resolved(),
670 encode_ref,
671 )
672 .unwrap();
673 let obj = result.as_object().unwrap();
674 assert_eq!(obj.get("a"), Some(&json!(1)));
675 assert_eq!(obj.get("b"), Some(&json!(2)));
676 }
677
678 #[test]
679 fn resolve_value_map_with_refs() {
680 let uid = Uuid::from_u128(4);
681 let mut resolved = BTreeMap::new();
682 resolved.insert(uid, 50_i64);
683 let result = resolve_value_for_type(
684 &FieldType::Map {
685 value: Box::new(FieldType::Ref {
686 target: "t".to_string(),
687 }),
688 },
689 json!({"x": uid.to_string()}),
690 &resolved,
691 encode_ref,
692 )
693 .unwrap();
694 assert_eq!(result.as_object().unwrap().get("x"), Some(&json!(50)));
695 }
696
697 #[test]
698 fn resolve_value_map_non_object_errors() {
699 let err = resolve_value_for_type(
700 &FieldType::Map {
701 value: Box::new(FieldType::String),
702 },
703 json!("not-object"),
704 &empty_resolved(),
705 encode_ref,
706 )
707 .unwrap_err();
708 assert!(err.to_string().contains("map value must be an object"));
709 }
710
711 #[test]
714 fn query_filters_scalar_key() {
715 let schema = simple_type_schema();
716 let key = Key::from(BTreeMap::from([("slug".to_string(), json!("fra1"))]));
717 let filters = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap();
718 assert_eq!(filters, vec![("slug".to_string(), "fra1".to_string())]);
719 }
720
721 #[test]
722 fn query_filters_numeric_key() {
723 let schema = TypeSchema {
724 key: BTreeMap::from([("id".to_string(), field_schema(FieldType::Int))]),
725 fields: BTreeMap::new(),
726 };
727 let key = Key::from(BTreeMap::from([("id".to_string(), json!(42))]));
728 let filters = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap();
729 assert_eq!(filters, vec![("id".to_string(), "42".to_string())]);
730 }
731
732 #[test]
733 fn query_filters_bool_key() {
734 let schema = TypeSchema {
735 key: BTreeMap::from([("active".to_string(), field_schema(FieldType::Bool))]),
736 fields: BTreeMap::new(),
737 };
738 let key = Key::from(BTreeMap::from([("active".to_string(), json!(true))]));
739 let filters = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap();
740 assert_eq!(filters, vec![("active".to_string(), "true".to_string())]);
741 }
742
743 #[test]
744 fn query_filters_ref_key() {
745 let uid = Uuid::from_u128(10);
746 let schema = TypeSchema {
747 key: BTreeMap::from([(
748 "site".to_string(),
749 field_schema(FieldType::Ref {
750 target: "dcim.site".to_string(),
751 }),
752 )]),
753 fields: BTreeMap::new(),
754 };
755 let key = Key::from(BTreeMap::from([(
756 "site".to_string(),
757 json!(uid.to_string()),
758 )]));
759 let mut resolved = BTreeMap::new();
760 resolved.insert(uid, 77_i64);
761 let filters = query_filters_from_key(&schema, &key, &resolved).unwrap();
762 assert_eq!(filters, vec![("site".to_string(), "77".to_string())]);
763 }
764
765 #[test]
766 fn query_filters_list_ref_key() {
767 let uid1 = Uuid::from_u128(1);
768 let uid2 = Uuid::from_u128(2);
769 let schema = TypeSchema {
770 key: BTreeMap::from([(
771 "tags".to_string(),
772 field_schema(FieldType::ListRef {
773 target: "extras.tag".to_string(),
774 }),
775 )]),
776 fields: BTreeMap::new(),
777 };
778 let key = Key::from(BTreeMap::from([(
779 "tags".to_string(),
780 json!([uid1.to_string(), uid2.to_string()]),
781 )]));
782 let mut resolved = BTreeMap::new();
783 resolved.insert(uid1, 100_i64);
784 resolved.insert(uid2, 200_i64);
785 let filters = query_filters_from_key(&schema, &key, &resolved).unwrap();
786 assert_eq!(
787 filters,
788 vec![
789 ("tags".to_string(), "100".to_string()),
790 ("tags".to_string(), "200".to_string()),
791 ]
792 );
793 }
794
795 #[test]
796 fn query_filters_missing_key_schema_errors() {
797 let schema = simple_type_schema();
798 let key = Key::from(BTreeMap::from([("nonexistent".to_string(), json!("x"))]));
799 let err = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap_err();
800 assert!(err.to_string().contains("missing schema for key field"));
801 }
802
803 #[test]
804 fn query_filters_null_scalar_errors() {
805 let schema = simple_type_schema();
806 let key = Key::from(BTreeMap::from([("slug".to_string(), Value::Null)]));
807 let err = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap_err();
808 assert!(err.to_string().contains("key value is null"));
809 }
810
811 #[test]
812 fn query_filters_list_ref_non_array_errors() {
813 let schema = TypeSchema {
814 key: BTreeMap::from([(
815 "tags".to_string(),
816 field_schema(FieldType::ListRef {
817 target: "t".to_string(),
818 }),
819 )]),
820 fields: BTreeMap::new(),
821 };
822 let key = Key::from(BTreeMap::from([("tags".to_string(), json!("not-array"))]));
823 let err = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap_err();
824 assert!(err.to_string().contains("key field tags must be an array"));
825 }
826
827 fn store_with_mixed_ids(type_name: &str) -> StateStore {
830 use crate::StateData;
831 use alembic_core::TypeName;
832
833 let mut data = StateData::default();
834 data.mappings
835 .entry(TypeName::new(type_name))
836 .or_default()
837 .extend([
838 (Uid::from_u128(1), BackendId::Int(5)),
839 (Uid::from_u128(2), BackendId::String("uuid-2".to_string())),
840 ]);
841 StateStore::new(None, data)
842 }
843
844 fn keep_int(b: &BackendId) -> Option<u64> {
845 match b {
846 BackendId::Int(i) => Some(*i),
847 _ => None,
848 }
849 }
850
851 fn keep_string(b: &BackendId) -> Option<String> {
852 match b {
853 BackendId::String(s) => Some(s.clone()),
854 _ => None,
855 }
856 }
857
858 #[test]
859 fn state_mappings_by_id_keeps_only_accepted_variant() {
860 let store = store_with_mixed_ids("dcim.site");
861
862 let ints = state_mappings_by_id(&store, keep_int);
863 assert_eq!(ints["dcim.site"].get(&5), Some(&Uid::from_u128(1)));
864 assert_eq!(ints["dcim.site"].len(), 1);
865
866 let strings = state_mappings_by_id(&store, keep_string);
867 assert_eq!(strings["dcim.site"].get("uuid-2"), Some(&Uid::from_u128(2)));
868 assert_eq!(strings["dcim.site"].len(), 1);
869 }
870
871 #[test]
872 fn resolved_ids_from_state_filters_by_variant() {
873 let store = store_with_mixed_ids("t");
874
875 let ints = resolved_ids_from_state(&store, keep_int);
876 assert_eq!(ints, BTreeMap::from([(Uid::from_u128(1), 5u64)]));
877
878 let strings = resolved_ids_from_state(&store, keep_string);
879 assert_eq!(
880 strings,
881 BTreeMap::from([(Uid::from_u128(2), "uuid-2".to_string())])
882 );
883 }
884
885 fn ref_type(target: &str) -> FieldType {
888 FieldType::Ref {
889 target: target.to_string(),
890 }
891 }
892
893 fn mappings_with(type_name: &str, backend_id: BackendId, uid: Uid) -> StateMappings {
894 let mut m = StateMappings::default();
895 m.insert(type_name, backend_id, uid);
896 m
897 }
898
899 #[test]
900 fn normalize_value_ref_maps_backend_id_to_uid() {
901 let uid = Uuid::from_u128(1);
902 let m = mappings_with("t", BackendId::Int(7), uid);
903 let out = normalize_value_for_type(&ref_type("t"), json!(7), &m);
904 assert_eq!(out, json!(uid.to_string()));
905 }
906
907 #[test]
908 fn normalize_value_ref_unknown_id_passes_through() {
909 let out = normalize_value_for_type(&ref_type("t"), json!(7), &StateMappings::default());
910 assert_eq!(out, json!(7));
911 }
912
913 #[test]
914 fn normalize_value_list_ref_maps_each_known() {
915 let uid = Uuid::from_u128(2);
916 let m = mappings_with("t", BackendId::Int(8), uid);
917 let out = normalize_value_for_type(
918 &FieldType::ListRef {
919 target: "t".to_string(),
920 },
921 json!([8, 9]),
922 &m,
923 );
924 assert_eq!(out, json!([uid.to_string(), 9]));
925 }
926
927 #[test]
928 fn normalize_value_ref_nested_in_list() {
929 let uid = Uuid::from_u128(3);
930 let m = mappings_with("t", BackendId::Int(10), uid);
931 let out = normalize_value_for_type(
932 &FieldType::List {
933 item: Box::new(ref_type("t")),
934 },
935 json!([10]),
936 &m,
937 );
938 assert_eq!(out, json!([uid.to_string()]));
939 }
940
941 #[test]
942 fn normalize_value_ref_nested_in_map() {
943 let uid = Uuid::from_u128(4);
944 let m = mappings_with("t", BackendId::String("s".to_string()), uid);
945 let out = normalize_value_for_type(
946 &FieldType::Map {
947 value: Box::new(ref_type("t")),
948 },
949 json!({ "k": "s" }),
950 &m,
951 );
952 assert_eq!(out, json!({ "k": uid.to_string() }));
953 }
954
955 #[test]
956 fn normalize_ref_value_object_shaped_brief() {
957 let uid = Uuid::from_u128(5);
958 let m = mappings_with("t", BackendId::Int(11), uid);
959 let out = normalize_ref_value(json!({ "id": 11, "display": "x" }), "t", &m);
960 assert_eq!(out, json!(uid.to_string()));
961 }
962
963 #[test]
964 fn backend_id_from_value_variants() {
965 assert_eq!(backend_id_from_value(&json!(42)), Some(BackendId::Int(42)));
966 assert_eq!(backend_id_from_value(&json!(-1)), None);
967 assert_eq!(
968 backend_id_from_value(&json!("abc")),
969 Some(BackendId::String("abc".to_string()))
970 );
971 assert_eq!(
972 backend_id_from_value(&json!({ "id": 3 })),
973 Some(BackendId::Int(3))
974 );
975 assert_eq!(backend_id_from_value(&Value::Null), None);
976 }
977
978 #[test]
979 fn normalize_attrs_refs_resolves_declared_fields() {
980 let uid = Uuid::from_u128(6);
981 let m = mappings_with("dcim.site", BackendId::Int(12), uid);
982 let schema = TypeSchema {
983 key: BTreeMap::new(),
984 fields: BTreeMap::from([("site".to_string(), field_schema(ref_type("dcim.site")))]),
985 };
986 let a = attrs(vec![("site", json!(12))]);
987 let out = normalize_attrs_refs(&a, &schema, &m);
988 assert_eq!(out.get("site"), Some(&json!(uid.to_string())));
989 }
990
991 #[test]
992 fn from_state_and_resolved_ids_identity_roundtrip() {
993 let store = store_with_mixed_ids("dcim.site");
994 let m = StateMappings::from_state(&store);
995 assert_eq!(
996 m.uid_for("dcim.site", &BackendId::Int(5)),
997 Some(Uid::from_u128(1))
998 );
999 let resolved = resolved_ids_identity(&store);
1000 assert_eq!(resolved.get(&Uid::from_u128(1)), Some(&BackendId::Int(5)));
1001 assert_eq!(
1002 resolved.get(&Uid::from_u128(2)),
1003 Some(&BackendId::String("uuid-2".to_string()))
1004 );
1005 }
1006}