Skip to main content

alembic_engine/
adapter_ops.rs

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
219/// project the state store into a per-type `backend-id -> uid` map, keeping only
220/// the backend ids `extract` accepts (the variant a given adapter retains).
221pub 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
238/// project the state store into a flat `uid -> backend-id` map, keeping only the
239/// backend ids `extract` accepts. companion to [`state_mappings_by_id`].
240pub 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/// per-type `backend-id -> uid` map for read-side ref normalization.
256#[derive(Debug, Default, Clone)]
257pub struct StateMappings {
258    by_type: BTreeMap<String, BTreeMap<BackendId, Uid>>,
259}
260
261impl StateMappings {
262    /// the canonical uid a backend id maps to for `type_name`, if known.
263    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    /// record a `backend-id -> uid` mapping for `type_name`.
270    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    /// project the whole state store into per-type backend-id -> uid mappings.
278    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
285/// project the state store into a flat `uid -> backend-id` map, keeping every
286/// mapping. identity companion to [`resolved_ids_from_state`].
287pub fn resolved_ids_identity(state: &StateStore) -> BTreeMap<Uid, BackendId> {
288    resolved_ids_from_state(state, |b| Some(b.clone()))
289}
290
291/// rewrite every reference-typed field of `attrs` from backend ids back to
292/// canonical uids. read-side inverse of `build_request_body`.
293pub 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
310/// read-side mirror of `resolve_value_for_type`: maps backend ids back to uids
311/// at each ref leaf, recursing into `list` and `map` field types.
312fn 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
349/// map one ref value's backend id back to its canonical uid, or leave it as-is
350/// when the id is unknown or the value is not a ref shape.
351fn 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
365/// read a backend id out of a raw json value: a number, a string, or an object
366/// with an `id` field (a nested brief).
367pub 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    // --- build_key_from_schema ---
426
427    #[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    // --- build_request_body ---
461
462    #[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    // --- resolve_value_for_type ---
508
509    #[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    // --- query_filters_from_key ---
712
713    #[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    // --- state projection helpers ---
828
829    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    // --- read-side ref normalization ---
886
887    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}