Skip to main content

ocel_etl/
staging.rs

1//! The loose working representation for ETL.
2//!
3//! Extraction rarely sees data in a valid order: events may reference objects
4//! that arrive on a later page, attribute schemas grow as new fields appear,
5//! and the same object is touched by many records. `StagingLog` absorbs that
6//! chaos — everything can be added in any order — and [`StagingLog::into_ocel`]
7//! is the single validation gate into a spec-conformant [`ocel::Ocel`].
8
9use std::collections::{BTreeMap, HashMap};
10
11use chrono::{DateTime, Utc};
12use ocel::{
13    AttrType, AttrValue, AttributeDefinition, Event, EventAttribute, EventType, Object,
14    ObjectAttribute, ObjectType, Ocel, Relationship, Violation,
15};
16
17/// An event observed during extraction (relationships may dangle until the
18/// referenced objects are added).
19#[derive(Debug, Clone)]
20pub struct StagingEvent {
21    pub id: String,
22    pub event_type: String,
23    pub time: DateTime<Utc>,
24    /// Attribute name/value pairs.
25    pub attributes: Vec<(String, AttrValue)>,
26    /// `E2O` targets as (`object_id`, qualifier).
27    pub relations: Vec<(String, String)>,
28}
29
30#[derive(Debug, Clone, Default)]
31struct StagingObject {
32    object_type: String,
33    /// (name, value, time) — dynamic attribute observations, any order.
34    attributes: Vec<(String, AttrValue, DateTime<Utc>)>,
35    /// `O2O` targets as (`target_id`, qualifier).
36    relations: Vec<(String, String)>,
37}
38
39/// The loose intermediate representation: order-free ingestion, dangling
40/// references allowed, schema grows as observed.
41#[derive(Debug, Default)]
42pub struct StagingLog {
43    events: Vec<StagingEvent>,
44    /// Insertion-ordered objects (id -> index into `objects`).
45    object_index: HashMap<String, usize>,
46    object_ids: Vec<String>,
47    objects: Vec<StagingObject>,
48    /// event type -> attribute -> observed type (first observation wins;
49    /// conflicting observations degrade to `String`).
50    event_schema: BTreeMap<String, BTreeMap<String, AttrType>>,
51    object_schema: BTreeMap<String, BTreeMap<String, AttrType>>,
52}
53
54fn observe(schema: &mut BTreeMap<String, AttrType>, name: &str, value: &AttrValue) {
55    let observed = value.attr_type();
56    match schema.get(name) {
57        None => {
58            schema.insert(name.to_owned(), observed);
59        }
60        Some(&declared) if declared != observed => {
61            schema.insert(name.to_owned(), AttrType::String);
62        }
63        Some(_) => {}
64    }
65}
66
67/// Coerce a value to its (possibly degraded) declared type.
68fn conform(value: AttrValue, declared: AttrType) -> AttrValue {
69    if value.attr_type() == declared {
70        value
71    } else {
72        AttrValue::String(value.to_text())
73    }
74}
75
76impl StagingLog {
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Re-stage an existing log so further data can merge into it (the basis
83    /// for incremental sync). Schemas are seeded from the declarations, so
84    /// attribute types survive the round trip.
85    ///
86    /// Once a log has passed the gate, `from_ocel(log).into_ocel()`
87    /// reproduces it unchanged.
88    #[must_use]
89    pub fn from_ocel(ocel: Ocel) -> Self {
90        let mut staging = Self::new();
91        for event_type in &ocel.event_types {
92            staging
93                .event_schema
94                .insert(event_type.name.clone(), seed(&event_type.attributes));
95        }
96        for object_type in &ocel.object_types {
97            staging
98                .object_schema
99                .insert(object_type.name.clone(), seed(&object_type.attributes));
100        }
101        for object in ocel.objects {
102            staging.upsert_object(&object.id, &object.object_type);
103            for attr in object.attributes {
104                staging.add_object_attribute(&object.id, &attr.name, attr.value, attr.time);
105            }
106            for rel in object.relationships {
107                staging.add_o2o(&object.id, &rel.object_id, &rel.qualifier);
108            }
109        }
110        for event in ocel.events {
111            staging.add_event(StagingEvent {
112                id: event.id,
113                event_type: event.event_type,
114                time: event.time,
115                attributes: event
116                    .attributes
117                    .into_iter()
118                    .map(|a| (a.name, a.value))
119                    .collect(),
120                relations: event
121                    .relationships
122                    .into_iter()
123                    .map(|r| (r.object_id, r.qualifier))
124                    .collect(),
125            });
126        }
127        staging
128    }
129
130    /// Add an event (any order; its object references may not exist yet).
131    pub fn add_event(&mut self, event: StagingEvent) {
132        let schema = self
133            .event_schema
134            .entry(event.event_type.clone())
135            .or_default();
136        for (name, value) in &event.attributes {
137            observe(schema, name, value);
138        }
139        self.events.push(event);
140    }
141
142    /// Ensure an object exists with the given type. Repeated calls merge; a
143    /// later non-empty type fills in a placeholder created by references.
144    pub fn upsert_object(&mut self, id: &str, object_type: &str) {
145        let index = self.object_slot(id);
146        if self.objects[index].object_type.is_empty() {
147            object_type.clone_into(&mut self.objects[index].object_type);
148        }
149        self.object_schema
150            .entry(object_type.to_owned())
151            .or_default();
152    }
153
154    /// Record a dynamic attribute observation for an object (created as a
155    /// placeholder if unseen).
156    pub fn add_object_attribute(
157        &mut self,
158        id: &str,
159        name: &str,
160        value: AttrValue,
161        time: DateTime<Utc>,
162    ) {
163        let index = self.object_slot(id);
164        let object_type = self.objects[index].object_type.clone();
165        if !object_type.is_empty() {
166            let schema = self.object_schema.entry(object_type).or_default();
167            observe(schema, name, &value);
168        }
169        self.objects[index]
170            .attributes
171            .push((name.to_owned(), value, time));
172    }
173
174    /// Record an `O2O` relation (either endpoint may not exist yet).
175    pub fn add_o2o(&mut self, source_id: &str, target_id: &str, qualifier: &str) {
176        let index = self.object_slot(source_id);
177        self.objects[index]
178            .relations
179            .push((target_id.to_owned(), qualifier.to_owned()));
180    }
181
182    /// Re-key every object id; `E2O` and `O2O` references follow. Old ids
183    /// mapping to the same new id **merge**: attribute observations and
184    /// relations append, the first non-empty object type wins. Identity
185    /// resolution is applying an alias table; anonymization is applying a
186    /// hash — both are instances of this.
187    pub fn map_object_ids(&mut self, f: impl Fn(&str) -> String) {
188        let old_ids = std::mem::take(&mut self.object_ids);
189        let old_objects = std::mem::take(&mut self.objects);
190        self.object_index.clear();
191        for (old_id, mut object) in old_ids.into_iter().zip(old_objects) {
192            for (target, _) in &mut object.relations {
193                *target = f(target);
194            }
195            let index = self.object_slot(&f(&old_id));
196            let slot = &mut self.objects[index];
197            if slot.object_type.is_empty() {
198                slot.object_type = object.object_type;
199            }
200            slot.attributes.append(&mut object.attributes);
201            slot.relations.append(&mut object.relations);
202        }
203        for event in &mut self.events {
204            for (target, _) in &mut event.relations {
205                *target = f(target);
206            }
207        }
208    }
209
210    /// Rewrite every event in place (rename types, edit attributes, re-point
211    /// relations). The event schema is rebuilt from the mapped events, so a
212    /// renamed type replaces its old entry instead of leaving it behind.
213    pub fn map_events(&mut self, mut f: impl FnMut(&mut StagingEvent)) {
214        for event in &mut self.events {
215            f(event);
216        }
217        self.rebuild_event_schema();
218    }
219
220    /// Keep only events matching the predicate. The event schema is rebuilt,
221    /// so a type whose last event is dropped disappears from the log.
222    pub fn retain_events(&mut self, mut pred: impl FnMut(&StagingEvent) -> bool) {
223        self.events.retain(|event| pred(event));
224        self.rebuild_event_schema();
225    }
226
227    fn rebuild_event_schema(&mut self) {
228        self.event_schema.clear();
229        for event in &self.events {
230            let schema = self
231                .event_schema
232                .entry(event.event_type.clone())
233                .or_default();
234            for (name, value) in &event.attributes {
235                observe(schema, name, value);
236            }
237        }
238    }
239
240    fn object_slot(&mut self, id: &str) -> usize {
241        if let Some(&index) = self.object_index.get(id) {
242            return index;
243        }
244        let index = self.objects.len();
245        self.object_index.insert(id.to_owned(), index);
246        self.object_ids.push(id.to_owned());
247        self.objects.push(StagingObject::default());
248        index
249    }
250
251    /// The validation gate: convert into a spec-conformant [`Ocel`].
252    ///
253    /// Late attribute observations are re-checked against the final schema
254    /// (values whose type degraded to string are stringified), then everything
255    /// is delegated to [`ocel::OcelBuilder`] — dangling references, duplicate
256    /// ids, and placeholder objects that never received a type surface as
257    /// [`Violation`]s.
258    ///
259    /// # Errors
260    ///
261    /// Returns every [`Violation`] found when the staged data does not form a
262    /// valid OCEL 2.0 log.
263    pub fn into_ocel(self) -> Result<Ocel, Vec<Violation>> {
264        let mut builder = Ocel::builder();
265
266        for (name, attrs) in &self.event_schema {
267            builder.add_event_type(EventType {
268                name: name.clone(),
269                attributes: attr_defs(attrs),
270            });
271        }
272        for (name, attrs) in &self.object_schema {
273            builder.add_object_type(ObjectType {
274                name: name.clone(),
275                attributes: attr_defs(attrs),
276            });
277        }
278
279        for event in self.events {
280            let schema = self.event_schema.get(&event.event_type);
281            let attributes = event
282                .attributes
283                .into_iter()
284                .map(|(name, value)| {
285                    let declared = schema
286                        .and_then(|s| s.get(&name).copied())
287                        .unwrap_or_else(|| value.attr_type());
288                    EventAttribute {
289                        value: conform(value, declared),
290                        name,
291                    }
292                })
293                .collect();
294            builder.add_event(Event {
295                id: event.id,
296                event_type: event.event_type,
297                time: event.time,
298                attributes,
299                relationships: relationships(event.relations),
300            });
301        }
302
303        for (id, object) in self.object_ids.into_iter().zip(self.objects) {
304            let schema = self.object_schema.get(&object.object_type);
305            let mut attributes: Vec<ObjectAttribute> = object
306                .attributes
307                .into_iter()
308                .map(|(name, value, time)| {
309                    let declared = schema
310                        .and_then(|s| s.get(&name).copied())
311                        .unwrap_or_else(|| value.attr_type());
312                    ObjectAttribute {
313                        value: conform(value, declared),
314                        name,
315                        time,
316                    }
317                })
318                .collect();
319            // merges (map_object_ids, re-staged increments) make exact
320            // duplicate observations likely — one (name, value, time) is one
321            // observation; first occurrence keeps its position. Post-conform,
322            // one attribute name has one declared type, so the value's text
323            // is a faithful key.
324            let mut seen: std::collections::HashSet<(String, String, DateTime<Utc>)> =
325                std::collections::HashSet::new();
326            attributes.retain(|a| seen.insert((a.name.clone(), a.value.to_text(), a.time)));
327            attributes.sort_by(|a, b| a.time.cmp(&b.time).then_with(|| a.name.cmp(&b.name)));
328            let mut relations = object.relations;
329            relations.sort();
330            relations.dedup();
331            builder.add_object(Object {
332                id,
333                object_type: object.object_type,
334                attributes,
335                relationships: relationships(relations),
336            });
337        }
338
339        builder.build()
340    }
341}
342
343fn seed(attributes: &[AttributeDefinition]) -> BTreeMap<String, AttrType> {
344    attributes
345        .iter()
346        .map(|a| (a.name.clone(), a.value_type))
347        .collect()
348}
349
350fn attr_defs(attrs: &BTreeMap<String, AttrType>) -> Vec<AttributeDefinition> {
351    attrs
352        .iter()
353        .map(|(name, &value_type)| AttributeDefinition {
354            name: name.clone(),
355            value_type,
356        })
357        .collect()
358}
359
360fn relationships(pairs: Vec<(String, String)>) -> Vec<Relationship> {
361    pairs
362        .into_iter()
363        .map(|(object_id, qualifier)| Relationship {
364            object_id,
365            qualifier,
366        })
367        .collect()
368}