Skip to main content

dataflow_rs/engine/
compiler.rs

1//! # Workflow Compilation Module
2//!
3//! Pre-compiles all JSONLogic expressions used by workflows and tasks at engine
4//! initialization. Each compiled `Arc<Logic>` is stored directly on the
5//! workflow/task/config struct that owns it — no central `logic_cache`, no
6//! index lookup, no bounds check on the hot path. The `Engine` is wrapped in
7//! `Arc` and is `Send + Sync` so the entire stack is safe to share across
8//! Tokio worker threads.
9
10use crate::engine::error::{DataflowError, Result};
11use crate::engine::functions::integration::{EnrichConfig, HttpCallConfig, PublishKafkaConfig};
12use crate::engine::functions::template::{Template, TemplateCompiler};
13use crate::engine::functions::{FilterConfig, LogConfig, MapConfig, ValidationConfig};
14use crate::engine::{FunctionConfig, Workflow};
15use datalogic_rs::{Engine, Logic};
16use log::debug;
17use serde_json::Value;
18use std::sync::Arc;
19
20/// Compiles JSONLogic expressions and stamps them onto workflow/task/config
21/// structs as `Option<Arc<Logic>>` slots.
22pub struct LogicCompiler {
23    /// Shared datalogic Engine used both for compilation and (later) evaluation.
24    engine: Arc<Engine>,
25    /// Handed to `AsyncFunctionHandler::compile_input` and used internally to
26    /// compile `Template` fields on the built-in integration configs. Wraps the
27    /// same `engine`, so a `Template` compiled here or by a custom handler is
28    /// evaluable by the engine that will run the message.
29    template_compiler: TemplateCompiler,
30}
31
32impl Default for LogicCompiler {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl LogicCompiler {
39    /// Create a new LogicCompiler with a fresh datalogic `Engine` configured for
40    /// templating mode (preserves object structure in JSONLogic operations).
41    pub fn new() -> Self {
42        let engine = Arc::new(Engine::builder().with_templating(true).build());
43        let template_compiler = TemplateCompiler::new(Arc::clone(&engine));
44        Self {
45            engine,
46            template_compiler,
47        }
48    }
49
50    /// Get the Engine instance
51    pub fn engine(&self) -> Arc<Engine> {
52        Arc::clone(&self.engine)
53    }
54
55    /// Consume the compiler and return the shared engine.
56    pub fn into_engine(self) -> Arc<Engine> {
57        self.engine
58    }
59
60    /// Compile all workflows and their tasks, returning them sorted by priority.
61    /// Returns `Err` on the first validation or compilation failure — engine
62    /// construction is fail-loud so misconfigured workflows can't silently
63    /// disappear at runtime.
64    pub fn compile_workflows(&self, workflows: Vec<Workflow>) -> Result<Vec<Workflow>> {
65        let mut compiled_workflows = Vec::with_capacity(workflows.len());
66
67        for mut workflow in workflows {
68            workflow.validate()?;
69
70            // Populate the cached Arc<str> ids so audit emission can refcount-bump
71            // rather than reallocate per AuditTrail entry.
72            workflow.id_arc = Arc::from(workflow.id.as_str());
73            for task in &mut workflow.tasks {
74                task.id_arc = Arc::from(task.id.as_str());
75            }
76
77            // Compile the workflow condition (defaults to `true`, which folds
78            // to `None` so the hot path skips the eval — see `compile_condition`).
79            let label = format!("workflow {} condition", workflow.id);
80            workflow.compiled_condition = self.compile_condition(&workflow.condition, &label)?;
81            debug!("Workflow {} condition compiled", workflow.id);
82
83            // Compile task conditions and function-specific logic.
84            self.compile_workflow_tasks(&mut workflow)?;
85
86            // Stamp whether every task is a synchronous built-in. A fully-sync
87            // workflow can be folded into a shared cross-workflow `with_arena`
88            // scope (no `.await`), so the message context is deep-walked into
89            // the arena once per *run* of consecutive fully-sync workflows
90            // instead of once per workflow. Any async/custom task forces the
91            // per-workflow `.await` path.
92            workflow.fully_sync = workflow.tasks.iter().all(|t| t.function.is_sync_builtin());
93
94            compiled_workflows.push(workflow);
95        }
96
97        // Sort by priority once at construction time
98        compiled_workflows.sort_by_key(|w| w.priority);
99        Ok(compiled_workflows)
100    }
101
102    /// Compile task conditions and function logic for a workflow
103    fn compile_workflow_tasks(&self, workflow: &mut Workflow) -> Result<()> {
104        for task in &mut workflow.tasks {
105            let label = format!("task {} condition (workflow {})", task.id, workflow.id);
106            task.compiled_condition = self.compile_condition(&task.condition, &label)?;
107
108            // Compile function-specific logic (map transformations, validation rules, …)
109            self.compile_function_logic(&mut task.function, &task.id, &workflow.id)?;
110        }
111        Ok(())
112    }
113
114    /// Compile function-specific logic based on function type
115    fn compile_function_logic(
116        &self,
117        function: &mut FunctionConfig,
118        task_id: &str,
119        workflow_id: &str,
120    ) -> Result<()> {
121        match function {
122            FunctionConfig::Map { input, .. } => {
123                self.compile_map_logic(input, task_id, workflow_id)
124            }
125            FunctionConfig::Validation { input, .. } => {
126                self.compile_validation_logic(input, task_id, workflow_id)
127            }
128            FunctionConfig::Filter { input, .. } => {
129                self.compile_filter_logic(input, task_id, workflow_id)
130            }
131            FunctionConfig::Log { input, .. } => {
132                self.compile_log_logic(input, task_id, workflow_id)
133            }
134            FunctionConfig::HttpCall { input, .. } => {
135                self.compile_http_call_logic(input, task_id, workflow_id)
136            }
137            FunctionConfig::Enrich { input, .. } => {
138                self.compile_enrich_logic(input, task_id, workflow_id)
139            }
140            FunctionConfig::PublishKafka { input, .. } => {
141                self.compile_publish_kafka_logic(input, task_id, workflow_id)
142            }
143            // No JSONLogic to compile, but the `data.{target}` write path is
144            // precomputed here (path string + pre-split parts) so the hot
145            // path never re-formats or re-splits it.
146            FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
147                input.precompute_target_path();
148                Ok(())
149            }
150            FunctionConfig::PublishJson { input, .. }
151            | FunctionConfig::PublishXml { input, .. } => {
152                input.precompute_target_path();
153                Ok(())
154            }
155            // Custom and other functions don't need pre-compilation
156            _ => Ok(()),
157        }
158    }
159
160    /// Compile a JSONLogic expression and return the `Arc<Logic>`. Errors are
161    /// surfaced as `DataflowError::LogicEvaluation` with the supplied
162    /// context label for debugging.
163    fn compile(&self, logic: &Value, ctx_label: &str) -> Result<Arc<Logic>> {
164        self.engine
165            .compile_arc(logic)
166            .map_err(|e| DataflowError::LogicEvaluation(format!("{}: {}", ctx_label, e)))
167    }
168
169    /// Compile a workflow/task *condition*, returning `None` when the source is
170    /// the literal `true`. A `None` condition is treated as "always run" by
171    /// `evaluate_condition` / `evaluate_condition_in_arena`, so the hot path
172    /// skips the `engine.evaluate` call — and, in the sync stretch, the
173    /// per-task arena context slice build — entirely for the overwhelmingly
174    /// common default `condition: true`. datalogic already folds a literal
175    /// `true` to a near-free literal-fast-path eval; this avoids even setting
176    /// up the call. Non-literal conditions (including `false` and any real
177    /// expression) compile as normal.
178    fn compile_condition(&self, condition: &Value, ctx_label: &str) -> Result<Option<Arc<Logic>>> {
179        if matches!(condition, Value::Bool(true)) {
180            return Ok(None);
181        }
182        Ok(Some(self.compile(condition, ctx_label)?))
183    }
184
185    /// Compile map transformation logic
186    fn compile_map_logic(
187        &self,
188        config: &mut MapConfig,
189        task_id: &str,
190        workflow_id: &str,
191    ) -> Result<()> {
192        for mapping in &mut config.mappings {
193            // Pre-split the dot path so the hot path doesn't re-split per
194            // write. The `#` prefix is preserved here — it's the explicit
195            // "treat this as an object key, not an array index" hint that
196            // `set_nested_value` consumes when deciding container shape; the
197            // strip happens at lookup time inside `*_parts` helpers.
198            let parts: Vec<Arc<str>> = mapping.path.split('.').map(Arc::from).collect();
199            mapping.path_parts = Arc::from(parts.into_boxed_slice());
200            mapping.path_arc = Arc::from(mapping.path.as_str());
201
202            let label = format!(
203                "map logic for task {} in workflow {} (path {})",
204                task_id, workflow_id, mapping.path
205            );
206            mapping.compiled_logic = Some(self.compile(&mapping.logic, &label)?);
207        }
208        Ok(())
209    }
210
211    /// Compile validation rule logic
212    fn compile_validation_logic(
213        &self,
214        config: &mut ValidationConfig,
215        task_id: &str,
216        workflow_id: &str,
217    ) -> Result<()> {
218        for (idx, rule) in config.rules.iter_mut().enumerate() {
219            let label = format!(
220                "validation rule {} for task {} in workflow {}",
221                idx, task_id, workflow_id
222            );
223            rule.compiled_logic = Some(self.compile(&rule.logic, &label)?);
224        }
225        Ok(())
226    }
227
228    /// Compile log message and field expressions
229    fn compile_log_logic(
230        &self,
231        config: &mut LogConfig,
232        task_id: &str,
233        workflow_id: &str,
234    ) -> Result<()> {
235        let msg_label = label("log message", task_id, workflow_id);
236        config.compiled_message = Some(self.compile(&config.message, &msg_label)?);
237
238        // Compile each field expression. Collect into a fresh Vec, then
239        // assign — keeps the immutable borrow of `config.fields` from
240        // overlapping with the mutable borrow of `config.compiled_fields`.
241        let mut compiled_fields = Vec::with_capacity(config.fields.len());
242        for (key, logic) in &config.fields {
243            let label = format!(
244                "log field '{}' for task {} in workflow {}",
245                key, task_id, workflow_id
246            );
247            compiled_fields.push((key.clone(), Some(self.compile(logic, &label)?)));
248        }
249        config.compiled_fields = compiled_fields;
250        Ok(())
251    }
252
253    /// Compile filter condition logic
254    fn compile_filter_logic(
255        &self,
256        config: &mut FilterConfig,
257        task_id: &str,
258        workflow_id: &str,
259    ) -> Result<()> {
260        let label = label("filter condition", task_id, workflow_id);
261        config.compiled_condition = Some(self.compile(&config.condition, &label)?);
262        Ok(())
263    }
264
265    /// Compile http_call JSONLogic expressions (path_logic, body_logic)
266    fn compile_http_call_logic(
267        &self,
268        config: &mut HttpCallConfig,
269        task_id: &str,
270        workflow_id: &str,
271    ) -> Result<()> {
272        self.compile_template_field(
273            &mut config.path_logic,
274            "http_call path_logic",
275            task_id,
276            workflow_id,
277        )?;
278        self.compile_template_field(
279            &mut config.body_logic,
280            "http_call body_logic",
281            task_id,
282            workflow_id,
283        )?;
284        Ok(())
285    }
286
287    /// Compile enrich JSONLogic expressions (path_logic)
288    fn compile_enrich_logic(
289        &self,
290        config: &mut EnrichConfig,
291        task_id: &str,
292        workflow_id: &str,
293    ) -> Result<()> {
294        self.compile_template_field(
295            &mut config.path_logic,
296            "enrich path_logic",
297            task_id,
298            workflow_id,
299        )
300    }
301
302    /// Compile publish_kafka JSONLogic expressions (key_logic, value_logic)
303    fn compile_publish_kafka_logic(
304        &self,
305        config: &mut PublishKafkaConfig,
306        task_id: &str,
307        workflow_id: &str,
308    ) -> Result<()> {
309        self.compile_template_field(
310            &mut config.key_logic,
311            "publish_kafka key_logic",
312            task_id,
313            workflow_id,
314        )?;
315        self.compile_template_field(
316            &mut config.value_logic,
317            "publish_kafka value_logic",
318            task_id,
319            workflow_id,
320        )?;
321        Ok(())
322    }
323
324    /// Compile an optional built-in integration `Template` field — `path_logic`,
325    /// `body_logic`, `key_logic`, `value_logic` — against `self.template_compiler`.
326    /// A `None` field is a no-op, matching every one of these fields being
327    /// optional. `what` labels the compile-error context as `"{what} for task
328    /// {task_id} in workflow {workflow_id}"`, e.g. `"http_call body_logic"`.
329    fn compile_template_field(
330        &self,
331        field: &mut Option<Template>,
332        what: &str,
333        task_id: &str,
334        workflow_id: &str,
335    ) -> Result<()> {
336        if let Some(t) = field {
337            t.compile(&self.template_compiler, &label(what, task_id, workflow_id))?;
338        }
339        Ok(())
340    }
341}
342
343/// Format a JSONLogic compile-error label as `"{what} for task {task_id} in
344/// workflow {workflow_id}"` — the shape shared by every built-in whose
345/// context needs no further detail (a few, like map mappings and validation
346/// rules, append per-item detail and format their own label instead).
347fn label(what: &str, task_id: &str, workflow_id: &str) -> String {
348    format!("{what} for task {task_id} in workflow {workflow_id}")
349}
350
351#[cfg(test)]
352mod tests {
353    //! Pins the datalogic operator semantics this crate's own behaviour
354    //! depends on. Not an attempt at a general operator-semantics table — that
355    //! was investigated and refused: `datalogic-rs` keeps `mod opcode;` private
356    //! and `OpCode` `pub(crate)`, so this crate could only hand-maintain the
357    //! same unverified table one layer lower, and it would actively mislead —
358    //! see `an_unrecognised_operator_is_not_an_error_under_templating` below,
359    //! which is exactly the case a static "known operators" table would get
360    //! wrong. Every value here was read from a live `datalogic_rs::Engine`
361    //! built the way `LogicCompiler::new` builds one, not assumed.
362    //!
363    //! If a `datalogic-rs` upgrade changes any of these, that is a real
364    //! behaviour change for every workflow in production — these tests exist
365    //! so it fails CI instead of surfacing as a support ticket.
366    //!
367    //! These values are also *feature*-dependent. This crate exposes the
368    //! `datalogic-rs` operator families as cargo features, all off by default.
369    //! Any test whose answer changes when a family is enabled carries a
370    //! `#[cfg(feature = ...)]` so **both** configurations stay pinned —
371    //! otherwise the `--all-features` CI run would be the only one checking
372    //! anything and the default build, which is what `cargo add dataflow-rs`
373    //! delivers, would go untested.
374
375    use super::*;
376    use serde_json::json;
377
378    /// The exact engine construction `LogicCompiler::new` uses: templating
379    /// enabled, plus whichever `datalogic-rs` operator families this crate's
380    /// cargo features turned on — none, by default. Which families are live is
381    /// fixed at compile time, so a test whose result depends on one must be
382    /// `#[cfg]`-gated rather than assuming the default build.
383    fn engine() -> Engine {
384        Engine::builder().with_templating(true).build()
385    }
386
387    fn eval(engine: &Engine, logic: &Value) -> Value {
388        let compiled = engine.compile_arc(logic).expect("should compile");
389        let ctx = datavalue::OwnedDataValue::from(&json!({}));
390        serde_json::from_str(
391            &engine
392                .session()
393                .eval_str(&compiled, &ctx)
394                .expect("should evaluate"),
395        )
396        .expect("eval_str output should be valid JSON")
397    }
398
399    #[test]
400    fn empty_operand_results_this_crate_would_silently_break_on() {
401        // A workflow author can write any of these — a map mapping folding an
402        // empty list, a filter condition over an empty selector — and the
403        // crate never validates operand count. If a datalogic upgrade changed
404        // any of these defaults, every workflow relying on the vacuous case
405        // would silently start producing a different value.
406        let e = engine();
407        for (logic, expected) in [
408            (json!({"and": []}), json!(null)),
409            (json!({"or": []}), json!(null)),
410            (json!({"+": []}), json!(0)),
411            (json!({"*": []}), json!(1)),
412            (json!({"cat": []}), json!("")),
413            (json!({"merge": []}), json!([])),
414            (json!({"missing": []}), json!([])),
415        ] {
416            assert_eq!(eval(&e, &logic), expected, "for {logic}");
417        }
418    }
419
420    #[test]
421    fn a_missing_var_path_resolves_to_null_not_an_error() {
422        // The exact mechanism behind the pitfall CLAUDE.md documents for
423        // `payload.*` expressions: a `var` over a path that does not resolve
424        // is `Null`, silently, never `Err`. `Template::eval` and the built-in
425        // `*_logic` fields inherit this — there is no engine-level signal that
426        // distinguishes "field absent" from "field is null".
427        let e = engine();
428        assert_eq!(
429            eval(&e, &json!({"var": "data.does_not_exist"})),
430            json!(null)
431        );
432    }
433
434    #[test]
435    fn truthy_falsy_matches_the_documented_semantics() {
436        // Verifies the claim in docs/src/advanced/jsonlogic.md's Truthy/Falsy
437        // section, which is a `json` fence and therefore NOT compiled by
438        // dataflow-docs-tests — this is the only check on that claim.
439        // Notable and easy to get wrong: an empty object `{}` is falsy here,
440        // unlike some JSONLogic implementations that treat any object as truthy.
441        let e = engine();
442        for (v, truthy) in [
443            (json!(0), false),
444            (json!(""), false),
445            (json!(false), false),
446            (json!(null), false),
447            (json!([]), false),
448            (json!({}), false),
449            (json!("x"), true),
450            (json!(1), true),
451        ] {
452            assert_eq!(
453                eval(&e, &json!({"!!": v})),
454                json!(truthy),
455                "truthiness of {v}"
456            );
457        }
458    }
459
460    #[test]
461    fn an_unrecognised_operator_is_not_an_error_under_templating() {
462        // The load-bearing fact behind #26's refusal of a static "known
463        // operators" table, and the reason `Template` documents itself as
464        // opt-in per field rather than a blanket JSON wrapper: under
465        // templating (which LogicCompiler and TemplateCompiler both enable),
466        // an outright typo neither fails to compile nor fails to evaluate. It
467        // echoes back as a literal structured object instead — a workflow
468        // author who mistypes an operator name gets silent pass-through, not a
469        // validation error. True under every feature combination, so this half
470        // of the tripwire is unconditional.
471        let e = engine();
472        let logic = json!({"totally_made_up_op_xyz": ["a", "b"]});
473        assert_eq!(
474            eval(&e, &logic),
475            logic,
476            "an unrecognised operator must echo back verbatim, not error"
477        );
478    }
479
480    /// With `ext-string` off, `starts_with` is not a name the engine knows, so
481    /// it is indistinguishable from the typo above: silent pass-through. This
482    /// is the failure mode a workflow author hits when they reach for an
483    /// operator whose family this build did not enable — no error, just a
484    /// wrong value.
485    #[cfg(not(feature = "ext-string"))]
486    #[test]
487    fn a_gated_operator_echoes_back_while_its_family_is_off() {
488        let e = engine();
489        let logic = json!({"starts_with": ["hello", "he"]});
490        assert_eq!(
491            eval(&e, &logic),
492            logic,
493            "an operator behind an unenabled family must echo back, not error"
494        );
495    }
496
497    /// The other side of the same coin, and the reason enabling a family is
498    /// not a no-op for existing workflows: `ext-string` converts a previously
499    /// inert `{"starts_with": [...]}` *literal* into a live operator call.
500    /// Anyone carrying such an object as data through a `map` mapping sees
501    /// their value silently replaced by the operator's result.
502    #[cfg(feature = "ext-string")]
503    #[test]
504    fn a_gated_operator_evaluates_once_its_family_is_on() {
505        let e = engine();
506        assert_eq!(
507            eval(&e, &json!({"starts_with": ["hello", "he"]})),
508            json!(true),
509            "with ext-string on, starts_with must evaluate, not echo"
510        );
511    }
512
513    /// `datetime` is the one family that is not confined to new operator
514    /// names. `datalogic-rs`'s comparison path probes *plain strings* for a
515    /// datetime/duration shape before falling back to byte comparison, so
516    /// `==` and the ordering operators change answers on date-shaped
517    /// operands. These two strings are different byte sequences naming the
518    /// same instant.
519    #[test]
520    fn datetime_feature_changes_plain_string_comparison() {
521        let e = engine();
522        let logic = json!({"==": ["2024-01-15T00:00:00Z", "2024-01-15T01:00:00+01:00"]});
523        #[cfg(feature = "datetime")]
524        assert_eq!(eval(&e, &logic), json!(true));
525        #[cfg(not(feature = "datetime"))]
526        assert_eq!(eval(&e, &logic), json!(false));
527    }
528
529    /// Each family's cargo feature actually reaches `datalogic-rs`. One
530    /// representative operator per family is enough — the feature either
531    /// forwards or it does not.
532    #[cfg(feature = "ext-string")]
533    #[test]
534    fn ext_string_feature_reaches_datalogic() {
535        let e = engine();
536        assert_eq!(eval(&e, &json!({"upper": "ab"})), json!("AB"));
537    }
538
539    #[cfg(feature = "ext-array")]
540    #[test]
541    fn ext_array_feature_reaches_datalogic() {
542        let e = engine();
543        assert_eq!(eval(&e, &json!({"sort": [[3, 1, 2]]})), json!([1, 2, 3]));
544    }
545
546    #[cfg(feature = "ext-math")]
547    #[test]
548    fn ext_math_feature_reaches_datalogic() {
549        let e = engine();
550        assert_eq!(eval(&e, &json!({"abs": -5})), json!(5));
551    }
552
553    #[cfg(feature = "ext-control")]
554    #[test]
555    fn ext_control_feature_reaches_datalogic() {
556        let e = engine();
557        assert_eq!(
558            eval(&e, &json!({"??": [null, "fallback"]})),
559            json!("fallback")
560        );
561    }
562
563    #[cfg(feature = "error-handling")]
564    #[test]
565    fn error_handling_feature_reaches_datalogic() {
566        // `error-handling` is the JSONLogic `try`/`throw` pair — unrelated to
567        // this crate's own always-on error handling.
568        let e = engine();
569        assert_eq!(
570            eval(&e, &json!({"try": [{"throw": "boom"}, "recovered"]})),
571            json!("recovered")
572        );
573    }
574
575    /// The `datetime` family's own operators. Their exact output depends on
576    /// the ambient clock and on format details this crate does not pin, so
577    /// assert only the property the feature actually buys: the operator is
578    /// recognised and evaluates, rather than echoing back as a literal.
579    #[cfg(feature = "datetime")]
580    #[test]
581    fn datetime_feature_reaches_datalogic() {
582        let e = engine();
583        let logic = json!({"now": []});
584        let result = eval(&e, &logic);
585        assert_ne!(result, logic, "with datetime on, `now` must not echo back");
586        assert!(!result.is_null(), "`now` should produce a value, got null");
587    }
588}