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