Skip to main content

ocel_transform/
apply.rs

1//! Applying a recipe: each step maps a valid log to a valid log.
2//!
3//! Event-level steps run through [`ocel_etl::StagingLog`] (whose gate
4//! re-validates), object-level steps use the core's OCEL-aware filters, and
5//! the final result is validated once more — a recipe can drop data, but it
6//! can never produce an inconsistent log.
7
8use std::collections::BTreeSet;
9
10use chrono::{DateTime, NaiveDate, Utc};
11use ocel::{AttrValue, Ocel, Violation};
12use ocel_etl::StagingLog;
13use regex::Regex;
14use thiserror::Error;
15
16use crate::recipe::{EventPredicate, Recipe, Step};
17
18#[derive(Debug, Error)]
19pub enum TransformError {
20    #[error("step {step}: invalid regex: {message}")]
21    BadRegex { step: usize, message: String },
22
23    #[error("step {step}: invalid time bound {value:?} (RFC 3339 or YYYY-MM-DD)")]
24    BadTime { step: usize, value: String },
25
26    #[error("step {step}: predicate has no conditions (it would drop every event)")]
27    EmptyPredicate { step: usize },
28
29    #[error("step {step}: value conditions (equals/matches/min/max) require `attr`")]
30    ValueConditionWithoutAttr { step: usize },
31
32    #[error("the transformed log failed validation: {0:?}")]
33    Invalid(Vec<Violation>),
34}
35
36/// Effect of one step, for honest reporting in CLIs and UIs.
37#[derive(Debug, Clone, serde::Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct StepReport {
40    pub step: String,
41    pub events_before: usize,
42    pub events_after: usize,
43    pub objects_before: usize,
44    pub objects_after: usize,
45}
46
47/// Apply every step of `recipe` in order.
48pub fn apply(recipe: &Recipe, log: Ocel) -> Result<(Ocel, Vec<StepReport>), TransformError> {
49    let mut log = log;
50    let mut reports = Vec::with_capacity(recipe.steps.len());
51    for (index, step) in recipe.steps.iter().enumerate() {
52        let events_before = log.events.len();
53        let objects_before = log.objects.len();
54        log = apply_step(index, step, log)?;
55        reports.push(StepReport {
56            step: step.label().to_owned(),
57            events_before,
58            events_after: log.events.len(),
59            objects_before,
60            objects_after: log.objects.len(),
61        });
62    }
63    log.validate().map_err(TransformError::Invalid)?;
64    Ok((log, reports))
65}
66
67/// A dropped event, summarized for a "this is what the recipe deletes"
68/// preview — machine cleaning must stay human-inspectable.
69#[derive(Debug, Clone, serde::Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct DroppedEvent {
72    pub id: String,
73    pub event_type: String,
74    pub time: DateTime<Utc>,
75    /// (name, value as text) pairs.
76    pub attributes: Vec<(String, String)>,
77}
78
79/// One step's effect plus a sample of the events it removed.
80#[derive(Debug, Clone, serde::Serialize)]
81#[serde(rename_all = "camelCase")]
82pub struct StepPreview {
83    #[serde(flatten)]
84    pub report: StepReport,
85    /// Up to `sample` of the events this step dropped.
86    pub dropped_events: Vec<DroppedEvent>,
87    /// Total events this step dropped (the sample may be shorter).
88    pub dropped_total: usize,
89}
90
91/// Like [`apply`], but additionally samples the events each step drops.
92/// Costs one id-set pass per step; meant for interactive previews.
93pub fn preview(
94    recipe: &Recipe,
95    log: Ocel,
96    sample: usize,
97) -> Result<(Ocel, Vec<StepPreview>), TransformError> {
98    let mut log = log;
99    let mut previews = Vec::with_capacity(recipe.steps.len());
100    for (index, step) in recipe.steps.iter().enumerate() {
101        let before = log.clone();
102        log = apply_step(index, step, log)?;
103        let after_ids: BTreeSet<&str> = log.events.iter().map(|e| e.id.as_str()).collect();
104        let dropped: Vec<&ocel::Event> = before
105            .events
106            .iter()
107            .filter(|e| !after_ids.contains(e.id.as_str()))
108            .collect();
109        let dropped_events = dropped
110            .iter()
111            .take(sample)
112            .map(|e| DroppedEvent {
113                id: e.id.clone(),
114                event_type: e.event_type.clone(),
115                time: e.time,
116                attributes: e
117                    .attributes
118                    .iter()
119                    .map(|a| (a.name.clone(), a.value.to_text()))
120                    .collect(),
121            })
122            .collect();
123        previews.push(StepPreview {
124            report: StepReport {
125                step: step.label().to_owned(),
126                events_before: before.events.len(),
127                events_after: log.events.len(),
128                objects_before: before.objects.len(),
129                objects_after: log.objects.len(),
130            },
131            dropped_events,
132            dropped_total: dropped.len(),
133        });
134    }
135    log.validate().map_err(TransformError::Invalid)?;
136    Ok((log, previews))
137}
138
139fn apply_step(index: usize, step: &Step, log: Ocel) -> Result<Ocel, TransformError> {
140    match step {
141        Step::DropEventTypes(names) => Ok(with_staging(log, |staging| {
142            staging.retain_events(|e| !names.contains(&e.event_type));
143        })),
144        Step::KeepEventTypes(names) => Ok(with_staging(log, |staging| {
145            staging.retain_events(|e| names.contains(&e.event_type));
146        })),
147        Step::DropEventsWhere(predicate) => {
148            let matcher = Matcher::compile(index, predicate)?;
149            Ok(with_staging(log, |staging| {
150                staging.retain_events(|e| !matcher.matches(&e.event_type, &e.attributes));
151            }))
152        }
153        Step::RenameEventTypes(renames) => Ok(with_staging(log, |staging| {
154            staging.map_events(|e| {
155                if let Some(new_name) = renames.get(&e.event_type) {
156                    e.event_type.clone_from(new_name);
157                }
158            });
159        })),
160        Step::TimeWindow(window) => {
161            let from = parse_bound(index, window.from.as_deref(), false)?;
162            let to = parse_bound(index, window.to.as_deref(), true)?;
163            Ok(with_staging(log, |staging| {
164                staging.retain_events(|e| {
165                    from.is_none_or(|f| e.time >= f) && to.is_none_or(|t| e.time < t)
166                });
167            }))
168        }
169        Step::KeepObjectTypes(names) => {
170            let names: Vec<&str> = names.iter().map(String::as_str).collect();
171            Ok(log.filter_object_types(&names))
172        }
173        Step::DropObjectsWithoutEvents => Ok(drop_objects_without_events(log)),
174        Step::MapObjectIds(table) => Ok(with_staging(log, |staging| {
175            staging.map_object_ids(|id| {
176                table
177                    .aliases
178                    .get(id)
179                    .cloned()
180                    .unwrap_or_else(|| id.to_owned())
181            });
182        })),
183    }
184}
185
186/// Round-trip through the staging representation; the gate cannot fail here
187/// because the input was valid and these transforms only drop or rename.
188fn with_staging(log: Ocel, f: impl FnOnce(&mut StagingLog)) -> Ocel {
189    let mut staging = StagingLog::from_ocel(log);
190    f(&mut staging);
191    staging
192        .into_ocel()
193        .expect("dropping or renaming events keeps a valid log valid")
194}
195
196fn drop_objects_without_events(log: Ocel) -> Ocel {
197    let referenced: BTreeSet<String> = log
198        .events
199        .iter()
200        .flat_map(|e| e.relationships.iter().map(|r| r.object_id.clone()))
201        .collect();
202    let objects = log
203        .objects
204        .into_iter()
205        .filter(|o| referenced.contains(&o.id))
206        .map(|mut o| {
207            o.relationships
208                .retain(|r| referenced.contains(&r.object_id));
209            o
210        })
211        .collect();
212    Ocel { objects, ..log }
213}
214
215/// A compiled event predicate.
216struct Matcher {
217    event_type: Option<String>,
218    attr: Option<String>,
219    equals: Option<String>,
220    regex: Option<Regex>,
221    min: Option<f64>,
222    max: Option<f64>,
223}
224
225impl Matcher {
226    fn compile(step: usize, p: &EventPredicate) -> Result<Matcher, TransformError> {
227        let has_value_condition =
228            p.equals.is_some() || p.matches.is_some() || p.min.is_some() || p.max.is_some();
229        if p.event_type.is_none() && p.attr.is_none() {
230            return Err(TransformError::EmptyPredicate { step });
231        }
232        if has_value_condition && p.attr.is_none() {
233            return Err(TransformError::ValueConditionWithoutAttr { step });
234        }
235        let regex = p
236            .matches
237            .as_deref()
238            .map(Regex::new)
239            .transpose()
240            .map_err(|e| TransformError::BadRegex {
241                step,
242                message: e.to_string(),
243            })?;
244        Ok(Matcher {
245            event_type: p.event_type.clone(),
246            attr: p.attr.clone(),
247            equals: p.equals.clone(),
248            regex,
249            min: p.min,
250            max: p.max,
251        })
252    }
253
254    fn matches(&self, event_type: &str, attributes: &[(String, AttrValue)]) -> bool {
255        if let Some(ty) = &self.event_type {
256            if event_type != ty {
257                return false;
258            }
259        }
260        let Some(attr) = &self.attr else {
261            return true; // type-only predicate
262        };
263        let Some((_, value)) = attributes.iter().find(|(name, _)| name == attr) else {
264            return false; // events without the attribute never match
265        };
266        let text = value.to_text();
267        if let Some(equals) = &self.equals {
268            if text != *equals {
269                return false;
270            }
271        }
272        if let Some(regex) = &self.regex {
273            if !regex.is_match(&text) {
274                return false;
275            }
276        }
277        if self.min.is_some() || self.max.is_some() {
278            // numeric conditions apply to numeric values only; intentional cast
279            #[allow(clippy::cast_precision_loss)]
280            let number = match value {
281                AttrValue::Integer(i) => Some(*i as f64),
282                AttrValue::Float(f) => Some(*f),
283                AttrValue::String(_) | AttrValue::Boolean(_) | AttrValue::Time(_) => None,
284            };
285            let Some(number) = number else {
286                return false;
287            };
288            if self.min.is_some_and(|min| number < min) {
289                return false;
290            }
291            if self.max.is_some_and(|max| number > max) {
292                return false;
293            }
294        }
295        true
296    }
297}
298
299/// RFC 3339, or a date: `from` dates mean that midnight, `to` dates mean the
300/// *following* midnight so the named day is included in the half-open window.
301fn parse_bound(
302    step: usize,
303    bound: Option<&str>,
304    is_to: bool,
305) -> Result<Option<DateTime<Utc>>, TransformError> {
306    let Some(raw) = bound else {
307        return Ok(None);
308    };
309    if let Ok(instant) = DateTime::parse_from_rfc3339(raw) {
310        return Ok(Some(instant.to_utc()));
311    }
312    let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") else {
313        return Err(TransformError::BadTime {
314            step,
315            value: raw.to_owned(),
316        });
317    };
318    let date = if is_to { date.succ_opt() } else { Some(date) };
319    let midnight =
320        date.and_then(|d| d.and_hms_opt(0, 0, 0))
321            .ok_or_else(|| TransformError::BadTime {
322                step,
323                value: raw.to_owned(),
324            })?;
325    Ok(Some(midnight.and_utc()))
326}