Skip to main content

alembic_engine/
types.rs

1//! core engine types and adapter contract.
2
3use alembic_core::{key_string, JsonMap, Key, Object, Schema, TypeName, Uid};
4use anyhow::{anyhow, Result};
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::fmt;
9use std::hash::{DefaultHasher, Hash, Hasher};
10
11/// generic backend identifier (integer or string/uuid).
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(untagged)]
14pub enum BackendId {
15    Int(u64),
16    String(String),
17}
18
19impl fmt::Display for BackendId {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            BackendId::Int(id) => write!(f, "{}", id),
23            BackendId::String(id) => write!(f, "{}", id),
24        }
25    }
26}
27
28impl From<u64> for BackendId {
29    fn from(id: u64) -> Self {
30        BackendId::Int(id)
31    }
32}
33
34impl From<String> for BackendId {
35    fn from(id: String) -> Self {
36        BackendId::String(id)
37    }
38}
39
40/// field-level change for an update op.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash, Eq)]
42pub struct FieldChange {
43    /// field name within attrs.
44    pub field: String,
45    /// previous value from observed state.
46    pub from: serde_json::Value,
47    /// desired value from the ir.
48    pub to: serde_json::Value,
49}
50
51/// plan operation.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash, Eq)]
53#[serde(tag = "op", rename_all = "snake_case")]
54pub enum Op {
55    /// create a new backend object.
56    Create {
57        uid: Uid,
58        type_name: TypeName,
59        desired: Object,
60    },
61    /// update an existing backend object.
62    Update {
63        uid: Uid,
64        type_name: TypeName,
65        desired: Object,
66        changes: Vec<FieldChange>,
67        #[serde(skip_serializing_if = "Option::is_none")]
68        backend_id: Option<BackendId>,
69    },
70    /// delete a backend object.
71    Delete {
72        uid: Uid,
73        type_name: TypeName,
74        key: Key,
75        #[serde(skip_serializing_if = "Option::is_none")]
76        backend_id: Option<BackendId>,
77    },
78}
79
80impl Op {
81    /// returns the ir uid for this operation.
82    pub fn uid(&self) -> Uid {
83        match self {
84            Op::Create { uid, .. } => *uid,
85            Op::Update { uid, .. } => *uid,
86            Op::Delete { uid, .. } => *uid,
87        }
88    }
89
90    /// returns the type name for this operation.
91    pub fn type_name(&self) -> &TypeName {
92        match self {
93            Op::Create { type_name, .. } => type_name,
94            Op::Update { type_name, .. } => type_name,
95            Op::Delete { type_name, .. } => type_name,
96        }
97    }
98
99    pub fn hashed(&self) -> u64 {
100        let mut hasher = DefaultHasher::new();
101        self.hash(&mut hasher);
102        hasher.finish()
103    }
104}
105
106/// full plan document.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Plan {
109    /// schema definitions required for apply.
110    pub schema: Schema,
111    /// ordered list of operations.
112    pub ops: Vec<Op>,
113    /// high-level summary of the plan.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub summary: Option<PlanSummary>,
116    /// read-only preview of the schema provisioning apply would perform, populated at
117    /// plan time. `None` when the backend cannot preview schema (or was not asked).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub schema_preview: Option<ProvisionReport>,
120}
121
122/// high-level summary of plan operations.
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct PlanSummary {
125    /// number of objects to create.
126    pub create: usize,
127    /// number of objects to update.
128    pub update: usize,
129    /// number of objects to delete.
130    pub delete: usize,
131}
132
133impl Plan {
134    /// build a summary for the current plan.
135    pub fn summary(&self) -> PlanSummary {
136        let mut summary = PlanSummary::default();
137        for op in &self.ops {
138            match op {
139                Op::Create { .. } => summary.create += 1,
140                Op::Update { .. } => summary.update += 1,
141                Op::Delete { .. } => summary.delete += 1,
142            }
143        }
144        summary
145    }
146}
147
148/// observed backend object representation.
149#[derive(Debug, Clone)]
150pub struct ObservedObject {
151    /// object type.
152    pub type_name: TypeName,
153    /// human key for matching.
154    pub key: Key,
155    /// observed attrs mapped to ir types.
156    pub attrs: JsonMap,
157    /// backend id when known.
158    pub backend_id: Option<BackendId>,
159}
160
161/// observed backend state indexed by id and key.
162#[derive(Debug, Default, Clone)]
163pub struct ObservedState {
164    /// observed objects keyed by backend id.
165    pub by_backend_id: BTreeMap<(TypeName, BackendId), ObservedObject>,
166    /// observed objects keyed by natural key.
167    pub by_key: BTreeMap<(TypeName, String), ObservedObject>,
168}
169
170impl ObservedState {
171    /// insert an observed object into both indexes.
172    /// Disallows duplicate backend ids.
173    pub fn insert(&mut self, object: ObservedObject) -> Result<()> {
174        if let Some(id) = &object.backend_id {
175            let key = (object.type_name.clone(), id.clone());
176            if self.by_backend_id.contains_key(&key) {
177                return Err(anyhow!(
178                    "ObservedState already contains an object with backend id {} for type {}",
179                    id,
180                    object.type_name
181                ));
182            }
183            self.by_backend_id.insert(key, object.clone());
184        }
185
186        let key = (object.type_name.clone(), key_string(&object.key));
187        if self.by_key.contains_key(&key) {
188            return Err(anyhow!(
189                "ObservedState already contains an object with natural key {:?}",
190                key
191            ));
192        }
193        self.by_key.insert(key, object);
194
195        Ok(())
196    }
197}
198
199/// result for a single applied operation.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct AppliedOp {
202    /// ir uid for the operation.
203    pub uid: Uid,
204    /// type for the operation.
205    pub type_name: TypeName,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    /// backend id returned by the adapter, if any.
208    pub backend_id: Option<BackendId>,
209}
210
211/// aggregated apply report.
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213pub struct ApplyReport {
214    /// list of operations applied by the adapter.
215    pub applied: Vec<AppliedOp>,
216    /// number of previously applied operations, only set when apply is accompanied by a journal
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub previously_applied_count: Option<usize>,
219    /// schema provisioning report (populated when ensure_schema runs).
220    #[serde(default)]
221    pub provision: ProvisionReport,
222}
223
224/// report from ensure_schema provisioning.
225#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226pub struct ProvisionReport {
227    /// custom fields created on the backend.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub created_fields: Vec<String>,
230    /// tags created on the backend.
231    #[serde(default, skip_serializing_if = "Vec::is_empty")]
232    pub created_tags: Vec<String>,
233    /// custom object types created on the backend.
234    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235    pub created_object_types: Vec<String>,
236    /// custom object fields created on the backend.
237    #[serde(default, skip_serializing_if = "Vec::is_empty")]
238    pub created_object_fields: Vec<String>,
239    /// object types deprecated on the backend.
240    #[serde(default, skip_serializing_if = "Vec::is_empty")]
241    pub deprecated_object_types: Vec<String>,
242    /// object fields deprecated on the backend.
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub deprecated_object_fields: Vec<String>,
245    /// object types deleted on the backend.
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub deleted_object_types: Vec<String>,
248    /// object fields deleted on the backend.
249    #[serde(default, skip_serializing_if = "Vec::is_empty")]
250    pub deleted_object_fields: Vec<String>,
251}
252
253impl ProvisionReport {
254    pub fn is_empty(&self) -> bool {
255        self.created_fields.is_empty()
256            && self.created_tags.is_empty()
257            && self.created_object_types.is_empty()
258            && self.created_object_fields.is_empty()
259            && self.deprecated_object_types.is_empty()
260            && self.deprecated_object_fields.is_empty()
261            && self.deleted_object_types.is_empty()
262            && self.deleted_object_fields.is_empty()
263    }
264}
265
266impl fmt::Display for ProvisionReport {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        if self.is_empty() {
269            return write!(f, "no schema changes");
270        }
271
272        let mut first = true;
273        let sections: &[(&str, &[String])] = &[
274            ("fields created", &self.created_fields),
275            ("tags created", &self.created_tags),
276            ("object types created", &self.created_object_types),
277            ("object fields created", &self.created_object_fields),
278            ("object types deprecated", &self.deprecated_object_types),
279            ("object fields deprecated", &self.deprecated_object_fields),
280            ("object types deleted", &self.deleted_object_types),
281            ("object fields deleted", &self.deleted_object_fields),
282        ];
283
284        for (label, items) in sections {
285            if items.is_empty() {
286                continue;
287            }
288            if !first {
289                write!(f, ", ")?;
290            }
291            write!(f, "{} {label}", items.len())?;
292            first = false;
293        }
294
295        Ok(())
296    }
297}
298
299/// read capability: observe backend state.
300#[async_trait]
301pub trait Observer: Send + Sync {
302    async fn read(
303        &self,
304        schema: &Schema,
305        types: &[TypeName],
306        state: &crate::state::StateStore,
307    ) -> anyhow::Result<ObservedState>;
308}
309
310/// write capability: apply a plan's operations.
311#[async_trait]
312pub trait Emitter: Send + Sync {
313    async fn write(
314        &self,
315        schema: &Schema,
316        ops: &[Op],
317        state: &crate::state::StateStore,
318    ) -> anyhow::Result<ApplyReport>;
319}
320
321/// full adapter contract for read+write backends; may also provision schema.
322#[async_trait]
323pub trait Adapter: Observer + Emitter {
324    async fn ensure_schema(&self, _schema: &Schema) -> anyhow::Result<ProvisionReport> {
325        Ok(ProvisionReport::default())
326    }
327
328    /// read-only counterpart to [`Adapter::ensure_schema`]: report what provisioning
329    /// apply would perform, writing nothing. `None` means this adapter cannot preview
330    /// schema (reported honestly, never as "no schema changes"); `Some(report)` is the
331    /// provisioning it would carry out, `Some(empty)` that there is nothing to provision.
332    async fn preview_schema(&self, _schema: &Schema) -> anyhow::Result<Option<ProvisionReport>> {
333        Ok(None)
334    }
335}
336
337/// a constructed backend, tagged with its capability.
338pub enum Backend {
339    /// read-only backend (e.g. peeringdb).
340    Observer(Box<dyn Observer>),
341    /// write-only backend (e.g. django codegen).
342    Emitter(Box<dyn Emitter>),
343    /// read+write backend.
344    Adapter(Box<dyn Adapter>),
345}
346
347impl Backend {
348    pub fn observer(&self) -> anyhow::Result<&dyn Observer> {
349        match self {
350            Backend::Observer(observer) => Ok(observer.as_ref()),
351            Backend::Adapter(adapter) => Ok(adapter.as_ref()),
352            Backend::Emitter(_) => Err(anyhow::anyhow!(
353                "backend is write-only; it cannot observe state"
354            )),
355        }
356    }
357
358    pub fn emitter(&self) -> anyhow::Result<&dyn Emitter> {
359        match self {
360            Backend::Emitter(emitter) => Ok(emitter.as_ref()),
361            Backend::Adapter(adapter) => Ok(adapter.as_ref()),
362            Backend::Observer(_) => Err(anyhow::anyhow!(
363                "backend is read-only; it cannot apply changes"
364            )),
365        }
366    }
367
368    pub fn adapter(&self) -> anyhow::Result<&dyn Adapter> {
369        match self {
370            Backend::Adapter(adapter) => Ok(adapter.as_ref()),
371            Backend::Observer(_) => Err(anyhow::anyhow!(
372                "backend is read-only; it cannot provision schema"
373            )),
374            Backend::Emitter(_) => Err(anyhow::anyhow!(
375                "backend is write-only; it cannot provision schema"
376            )),
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use alembic_core::{Key, TypeName, Uid};
385
386    #[test]
387    fn backend_id_serialization() {
388        let int_id = BackendId::Int(123);
389        let json = serde_json::to_string(&int_id).unwrap();
390        assert_eq!(json, "123");
391        let back: BackendId = serde_json::from_str(&json).unwrap();
392        assert_eq!(back, int_id);
393
394        let str_id = BackendId::String("uuid".to_string());
395        let json = serde_json::to_string(&str_id).unwrap();
396        assert_eq!(json, "\"uuid\"");
397        let back: BackendId = serde_json::from_str(&json).unwrap();
398        assert_eq!(back, str_id);
399    }
400
401    #[test]
402    fn provision_report_defaults_omitted_lists() {
403        // a non-Rust ensure_schema adapter that provisioned an object type but no
404        // custom fields/tags naturally omits the empty lists; that must deserialize.
405        let report: ProvisionReport =
406            serde_json::from_value(serde_json::json!({"created_object_types": ["dcim.site"]}))
407                .unwrap();
408        assert!(report.created_fields.is_empty());
409        assert!(report.created_tags.is_empty());
410        assert_eq!(report.created_object_types, ["dcim.site"]);
411
412        // the whole report deserializes from an empty object.
413        assert!(serde_json::from_str::<ProvisionReport>("{}")
414            .unwrap()
415            .is_empty());
416    }
417
418    #[test]
419    fn op_helpers() {
420        let uid = Uid::from_u128(1);
421        let type_name = TypeName::new("test.type");
422        let op = Op::Delete {
423            uid,
424            type_name: type_name.clone(),
425            key: Key::default(),
426            backend_id: None,
427        };
428        assert_eq!(op.uid(), uid);
429        assert_eq!(op.type_name(), &type_name);
430    }
431}