Skip to main content

alembic_engine/
adapter_ops.rs

1use crate::AdapterApplyError;
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#[cfg(test)]
220mod tests {
221    use super::*;
222    use alembic_core::{FieldSchema, FieldType, JsonMap, Key, TypeSchema};
223    use serde_json::json;
224    use uuid::Uuid;
225
226    fn field_schema(r#type: FieldType) -> FieldSchema {
227        FieldSchema {
228            r#type,
229            required: false,
230            nullable: false,
231            format: None,
232            pattern: None,
233            description: None,
234        }
235    }
236
237    fn simple_type_schema() -> TypeSchema {
238        TypeSchema {
239            key: BTreeMap::from([("slug".to_string(), field_schema(FieldType::Slug))]),
240            fields: BTreeMap::from([
241                ("name".to_string(), field_schema(FieldType::String)),
242                ("count".to_string(), field_schema(FieldType::Int)),
243            ]),
244        }
245    }
246
247    fn attrs(pairs: Vec<(&str, Value)>) -> JsonMap {
248        JsonMap::from(
249            pairs
250                .into_iter()
251                .map(|(k, v)| (k.to_string(), v))
252                .collect::<BTreeMap<_, _>>(),
253        )
254    }
255
256    fn empty_resolved() -> BTreeMap<Uid, i64> {
257        BTreeMap::new()
258    }
259
260    fn encode_ref(id: &i64) -> Value {
261        json!(id)
262    }
263
264    // --- build_key_from_schema ---
265
266    #[test]
267    fn build_key_from_schema_extracts_key_fields() {
268        let schema = simple_type_schema();
269        let a = attrs(vec![("slug", json!("fra1")), ("name", json!("FRA1"))]);
270        let key = build_key_from_schema(&schema, &a).unwrap();
271        assert_eq!(key.get("slug"), Some(&json!("fra1")));
272        assert_eq!(key.len(), 1);
273    }
274
275    #[test]
276    fn build_key_from_schema_composite_key() {
277        let schema = TypeSchema {
278            key: BTreeMap::from([
279                ("site".to_string(), field_schema(FieldType::String)),
280                ("name".to_string(), field_schema(FieldType::String)),
281            ]),
282            fields: BTreeMap::new(),
283        };
284        let a = attrs(vec![("site", json!("fra1")), ("name", json!("eth0"))]);
285        let key = build_key_from_schema(&schema, &a).unwrap();
286        assert_eq!(key.len(), 2);
287        assert_eq!(key.get("site"), Some(&json!("fra1")));
288        assert_eq!(key.get("name"), Some(&json!("eth0")));
289    }
290
291    #[test]
292    fn build_key_from_schema_missing_field_errors() {
293        let schema = simple_type_schema();
294        let a = attrs(vec![("name", json!("FRA1"))]);
295        let err = build_key_from_schema(&schema, &a).unwrap_err();
296        assert!(err.to_string().contains("missing key field slug"));
297    }
298
299    // --- build_request_body ---
300
301    #[test]
302    fn build_request_body_scalar_fields() {
303        let schema = simple_type_schema();
304        let a = attrs(vec![("name", json!("FRA1")), ("count", json!(42))]);
305        let body = build_request_body(&schema, &a, &empty_resolved(), encode_ref).unwrap();
306        let obj = body.as_object().unwrap();
307        assert_eq!(obj.get("name"), Some(&json!("FRA1")));
308        assert_eq!(obj.get("count"), Some(&json!(42)));
309    }
310
311    #[test]
312    fn build_request_body_null_value_passes_through() {
313        let schema = simple_type_schema();
314        let a = attrs(vec![("name", Value::Null)]);
315        let body = build_request_body(&schema, &a, &empty_resolved(), encode_ref).unwrap();
316        assert_eq!(body.as_object().unwrap().get("name"), Some(&Value::Null));
317    }
318
319    #[test]
320    fn build_request_body_resolves_ref() {
321        let uid = Uuid::from_u128(1);
322        let schema = TypeSchema {
323            key: BTreeMap::new(),
324            fields: BTreeMap::from([(
325                "site".to_string(),
326                field_schema(FieldType::Ref {
327                    target: "dcim.site".to_string(),
328                }),
329            )]),
330        };
331        let a = attrs(vec![("site", json!(uid.to_string()))]);
332        let mut resolved = BTreeMap::new();
333        resolved.insert(uid, 99_i64);
334        let body = build_request_body(&schema, &a, &resolved, encode_ref).unwrap();
335        assert_eq!(body.as_object().unwrap().get("site"), Some(&json!(99)));
336    }
337
338    #[test]
339    fn build_request_body_missing_schema_errors() {
340        let schema = simple_type_schema();
341        let a = attrs(vec![("nonexistent", json!("x"))]);
342        let err = build_request_body(&schema, &a, &empty_resolved(), encode_ref).unwrap_err();
343        assert!(err.to_string().contains("missing schema for field"));
344    }
345
346    // --- resolve_value_for_type ---
347
348    #[test]
349    fn resolve_value_scalar_passthrough() {
350        let val = json!("hello");
351        let result = resolve_value_for_type(
352            &FieldType::String,
353            val.clone(),
354            &empty_resolved(),
355            encode_ref,
356        )
357        .unwrap();
358        assert_eq!(result, val);
359    }
360
361    #[test]
362    fn resolve_value_ref() {
363        let uid = Uuid::from_u128(5);
364        let mut resolved = BTreeMap::new();
365        resolved.insert(uid, 42_i64);
366        let result = resolve_value_for_type(
367            &FieldType::Ref {
368                target: "t".to_string(),
369            },
370            json!(uid.to_string()),
371            &resolved,
372            encode_ref,
373        )
374        .unwrap();
375        assert_eq!(result, json!(42));
376    }
377
378    #[test]
379    fn resolve_value_ref_missing_uid_errors() {
380        let uid = Uuid::from_u128(99);
381        let err = resolve_value_for_type(
382            &FieldType::Ref {
383                target: "t".to_string(),
384            },
385            json!(uid.to_string()),
386            &empty_resolved(),
387            encode_ref,
388        )
389        .unwrap_err();
390        assert!(err.to_string().contains("missing referenced uid"));
391    }
392
393    #[test]
394    fn resolve_value_ref_non_string_errors() {
395        let err = resolve_value_for_type(
396            &FieldType::Ref {
397                target: "t".to_string(),
398            },
399            json!(123),
400            &empty_resolved(),
401            encode_ref,
402        )
403        .unwrap_err();
404        assert!(err.to_string().contains("ref value must be a uuid string"));
405    }
406
407    #[test]
408    fn resolve_value_ref_invalid_uuid_errors() {
409        let err = resolve_value_for_type(
410            &FieldType::Ref {
411                target: "t".to_string(),
412            },
413            json!("not-a-uuid"),
414            &empty_resolved(),
415            encode_ref,
416        )
417        .unwrap_err();
418        assert!(err.to_string().contains("ref value is not a uuid"));
419    }
420
421    #[test]
422    fn resolve_value_list_ref() {
423        let uid1 = Uuid::from_u128(1);
424        let uid2 = Uuid::from_u128(2);
425        let mut resolved = BTreeMap::new();
426        resolved.insert(uid1, 10_i64);
427        resolved.insert(uid2, 20_i64);
428        let result = resolve_value_for_type(
429            &FieldType::ListRef {
430                target: "t".to_string(),
431            },
432            json!([uid1.to_string(), uid2.to_string()]),
433            &resolved,
434            encode_ref,
435        )
436        .unwrap();
437        assert_eq!(result, json!([10, 20]));
438    }
439
440    #[test]
441    fn resolve_value_list_ref_non_array_errors() {
442        let err = resolve_value_for_type(
443            &FieldType::ListRef {
444                target: "t".to_string(),
445            },
446            json!("not-array"),
447            &empty_resolved(),
448            encode_ref,
449        )
450        .unwrap_err();
451        assert!(err.to_string().contains("list_ref value must be an array"));
452    }
453
454    #[test]
455    fn resolve_value_list_scalars() {
456        let result = resolve_value_for_type(
457            &FieldType::List {
458                item: Box::new(FieldType::String),
459            },
460            json!(["a", "b"]),
461            &empty_resolved(),
462            encode_ref,
463        )
464        .unwrap();
465        assert_eq!(result, json!(["a", "b"]));
466    }
467
468    #[test]
469    fn resolve_value_list_of_refs() {
470        let uid = Uuid::from_u128(3);
471        let mut resolved = BTreeMap::new();
472        resolved.insert(uid, 7_i64);
473        let result = resolve_value_for_type(
474            &FieldType::List {
475                item: Box::new(FieldType::Ref {
476                    target: "t".to_string(),
477                }),
478            },
479            json!([uid.to_string()]),
480            &resolved,
481            encode_ref,
482        )
483        .unwrap();
484        assert_eq!(result, json!([7]));
485    }
486
487    #[test]
488    fn resolve_value_list_non_array_errors() {
489        let err = resolve_value_for_type(
490            &FieldType::List {
491                item: Box::new(FieldType::String),
492            },
493            json!("not-array"),
494            &empty_resolved(),
495            encode_ref,
496        )
497        .unwrap_err();
498        assert!(err.to_string().contains("list value must be an array"));
499    }
500
501    #[test]
502    fn resolve_value_map_scalars() {
503        let result = resolve_value_for_type(
504            &FieldType::Map {
505                value: Box::new(FieldType::Int),
506            },
507            json!({"a": 1, "b": 2}),
508            &empty_resolved(),
509            encode_ref,
510        )
511        .unwrap();
512        let obj = result.as_object().unwrap();
513        assert_eq!(obj.get("a"), Some(&json!(1)));
514        assert_eq!(obj.get("b"), Some(&json!(2)));
515    }
516
517    #[test]
518    fn resolve_value_map_with_refs() {
519        let uid = Uuid::from_u128(4);
520        let mut resolved = BTreeMap::new();
521        resolved.insert(uid, 50_i64);
522        let result = resolve_value_for_type(
523            &FieldType::Map {
524                value: Box::new(FieldType::Ref {
525                    target: "t".to_string(),
526                }),
527            },
528            json!({"x": uid.to_string()}),
529            &resolved,
530            encode_ref,
531        )
532        .unwrap();
533        assert_eq!(result.as_object().unwrap().get("x"), Some(&json!(50)));
534    }
535
536    #[test]
537    fn resolve_value_map_non_object_errors() {
538        let err = resolve_value_for_type(
539            &FieldType::Map {
540                value: Box::new(FieldType::String),
541            },
542            json!("not-object"),
543            &empty_resolved(),
544            encode_ref,
545        )
546        .unwrap_err();
547        assert!(err.to_string().contains("map value must be an object"));
548    }
549
550    // --- query_filters_from_key ---
551
552    #[test]
553    fn query_filters_scalar_key() {
554        let schema = simple_type_schema();
555        let key = Key::from(BTreeMap::from([("slug".to_string(), json!("fra1"))]));
556        let filters = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap();
557        assert_eq!(filters, vec![("slug".to_string(), "fra1".to_string())]);
558    }
559
560    #[test]
561    fn query_filters_numeric_key() {
562        let schema = TypeSchema {
563            key: BTreeMap::from([("id".to_string(), field_schema(FieldType::Int))]),
564            fields: BTreeMap::new(),
565        };
566        let key = Key::from(BTreeMap::from([("id".to_string(), json!(42))]));
567        let filters = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap();
568        assert_eq!(filters, vec![("id".to_string(), "42".to_string())]);
569    }
570
571    #[test]
572    fn query_filters_bool_key() {
573        let schema = TypeSchema {
574            key: BTreeMap::from([("active".to_string(), field_schema(FieldType::Bool))]),
575            fields: BTreeMap::new(),
576        };
577        let key = Key::from(BTreeMap::from([("active".to_string(), json!(true))]));
578        let filters = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap();
579        assert_eq!(filters, vec![("active".to_string(), "true".to_string())]);
580    }
581
582    #[test]
583    fn query_filters_ref_key() {
584        let uid = Uuid::from_u128(10);
585        let schema = TypeSchema {
586            key: BTreeMap::from([(
587                "site".to_string(),
588                field_schema(FieldType::Ref {
589                    target: "dcim.site".to_string(),
590                }),
591            )]),
592            fields: BTreeMap::new(),
593        };
594        let key = Key::from(BTreeMap::from([(
595            "site".to_string(),
596            json!(uid.to_string()),
597        )]));
598        let mut resolved = BTreeMap::new();
599        resolved.insert(uid, 77_i64);
600        let filters = query_filters_from_key(&schema, &key, &resolved).unwrap();
601        assert_eq!(filters, vec![("site".to_string(), "77".to_string())]);
602    }
603
604    #[test]
605    fn query_filters_list_ref_key() {
606        let uid1 = Uuid::from_u128(1);
607        let uid2 = Uuid::from_u128(2);
608        let schema = TypeSchema {
609            key: BTreeMap::from([(
610                "tags".to_string(),
611                field_schema(FieldType::ListRef {
612                    target: "extras.tag".to_string(),
613                }),
614            )]),
615            fields: BTreeMap::new(),
616        };
617        let key = Key::from(BTreeMap::from([(
618            "tags".to_string(),
619            json!([uid1.to_string(), uid2.to_string()]),
620        )]));
621        let mut resolved = BTreeMap::new();
622        resolved.insert(uid1, 100_i64);
623        resolved.insert(uid2, 200_i64);
624        let filters = query_filters_from_key(&schema, &key, &resolved).unwrap();
625        assert_eq!(
626            filters,
627            vec![
628                ("tags".to_string(), "100".to_string()),
629                ("tags".to_string(), "200".to_string()),
630            ]
631        );
632    }
633
634    #[test]
635    fn query_filters_missing_key_schema_errors() {
636        let schema = simple_type_schema();
637        let key = Key::from(BTreeMap::from([("nonexistent".to_string(), json!("x"))]));
638        let err = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap_err();
639        assert!(err.to_string().contains("missing schema for key field"));
640    }
641
642    #[test]
643    fn query_filters_null_scalar_errors() {
644        let schema = simple_type_schema();
645        let key = Key::from(BTreeMap::from([("slug".to_string(), Value::Null)]));
646        let err = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap_err();
647        assert!(err.to_string().contains("key value is null"));
648    }
649
650    #[test]
651    fn query_filters_list_ref_non_array_errors() {
652        let schema = TypeSchema {
653            key: BTreeMap::from([(
654                "tags".to_string(),
655                field_schema(FieldType::ListRef {
656                    target: "t".to_string(),
657                }),
658            )]),
659            fields: BTreeMap::new(),
660        };
661        let key = Key::from(BTreeMap::from([("tags".to_string(), json!("not-array"))]));
662        let err = query_filters_from_key(&schema, &key, &empty_resolved()).unwrap_err();
663        assert!(err.to_string().contains("key field tags must be an array"));
664    }
665}