1use 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#[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
47pub 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#[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 pub attributes: Vec<(String, String)>,
77}
78
79#[derive(Debug, Clone, serde::Serialize)]
81#[serde(rename_all = "camelCase")]
82pub struct StepPreview {
83 #[serde(flatten)]
84 pub report: StepReport,
85 pub dropped_events: Vec<DroppedEvent>,
87 pub dropped_total: usize,
89}
90
91pub 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
186fn 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
215struct 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; };
263 let Some((_, value)) = attributes.iter().find(|(name, _)| name == attr) else {
264 return false; };
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 #[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
299fn 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}