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::{
14    FilterConfig, LogConfig, MapConfig, ParseConfig, PublishConfig, ValidationConfig,
15};
16use crate::engine::secrets::{SECRET_OPERATOR, SecretOperator, Secrets};
17use crate::engine::{FunctionConfig, Workflow};
18use datalogic_rs::{CustomOperator, Engine, Logic};
19use log::debug;
20use serde_json::Value;
21use std::collections::HashMap;
22use std::sync::Arc;
23
24/// The template-key escape prefix, stripped from every object key in a
25/// JSONLogic template and blocking that key from resolving as an operator.
26///
27/// `{"$cat": ["a", "b"]}` is the literal object `{"cat": ["a", "b"]}`;
28/// `{"cat": ["a", "b"]}` is still the `cat` operator; `{"$$cat": …}` is the
29/// literal `{"$cat": …}`. Exactly one prefix is stripped per key.
30///
31/// Always on and not configurable. Templating mode makes every single-key
32/// object an operator invocation, so before this a literal object whose key
33/// collided with an operator name was *inexpressible* — which is what forced
34/// the `path`/`path_logic`-style field pairs and kept `Template` opt-in per
35/// field. One prefix everywhere is what lets those collapse.
36///
37/// `$` rather than a rarer sigil because it is the spelling `datalogic-rs`'s
38/// own JS bindings document. The cost is that a template emitting genuinely
39/// `$`-prefixed keys — MongoDB (`$set`, `$oid`), JSON Schema (`$schema`,
40/// `$ref`) — must double them to `$$set`. [`crate::IssueCode::EscapedTemplateKey`]
41/// reports every escaped key so that migration is mechanical.
42pub(crate) const TEMPLATE_KEY_ESCAPE: char = '$';
43
44/// The datalogic engine configuration this crate evaluates against: templating
45/// mode plus [`TEMPLATE_KEY_ESCAPE`].
46///
47/// Every datalogic engine built anywhere in this crate — production and tests
48/// alike — starts here. Both settings change what an expression *means*, so a
49/// second construction site that forgot one would make tests agree with an
50/// engine that never runs.
51pub(crate) fn datalogic_engine_builder() -> datalogic_rs::EngineBuilder {
52    Engine::builder()
53        .with_templating(true)
54        .with_template_key_escape(TEMPLATE_KEY_ESCAPE)
55}
56
57/// Adapter handing a shared operator to the datalogic builder, which takes
58/// ownership of what it registers. The `Arc` is the point: one registration
59/// (held by [`crate::Engine`]) outlives any single datalogic engine and is
60/// re-applied on every rebuild — without it, custom operators would silently
61/// vanish at the first [`crate::Engine::with_new_workflows`] hot reload.
62struct SharedOperator(Arc<dyn CustomOperator>);
63
64impl CustomOperator for SharedOperator {
65    #[inline]
66    fn evaluate<'a>(
67        &self,
68        args: &[&'a datalogic_rs::DataValue<'a>],
69        ctx: &mut datalogic_rs::operator::EvalContext<'_, 'a>,
70        arena: &'a datalogic_rs::bumpalo::Bump,
71    ) -> datalogic_rs::Result<&'a datalogic_rs::DataValue<'a>> {
72        self.0.evaluate(args, ctx, arena)
73    }
74}
75
76/// Compiles JSONLogic expressions and stamps them onto workflow/task/config
77/// structs as `Option<Arc<Logic>>` slots.
78pub struct LogicCompiler {
79    /// Shared datalogic Engine used both for compilation and (later) evaluation.
80    engine: Arc<Engine>,
81    /// Handed to `AsyncFunctionHandler::compile_input` and used internally to
82    /// compile `Template` fields on the built-in integration configs. Wraps the
83    /// same `engine`, so a `Template` compiled here or by a custom handler is
84    /// evaluable by the engine that will run the message.
85    template_compiler: TemplateCompiler,
86}
87
88impl Default for LogicCompiler {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl LogicCompiler {
95    /// Create a new LogicCompiler with a fresh datalogic `Engine` configured for
96    /// templating mode (preserves object structure in JSONLogic operations).
97    pub fn new() -> Self {
98        Self::with_operators(&HashMap::new())
99    }
100
101    /// As [`LogicCompiler::new`], with `operators` registered on the datalogic
102    /// engine before it is built — registration there is builder-only, so this
103    /// is the single point where custom operators can enter.
104    pub fn with_operators(operators: &HashMap<String, Arc<dyn CustomOperator>>) -> Self {
105        Self::with_operators_and_secrets(operators, &Arc::new(Secrets::empty()), None)
106    }
107
108    /// As [`LogicCompiler::with_operators`], with the `secret` operator backed
109    /// by `secrets`. Every datalogic engine this crate builds goes through
110    /// here, so the operator is registered whether or not the store is empty —
111    /// see the `secrets` module for why that matters in templating mode.
112    pub(crate) fn with_operators_and_secrets(
113        operators: &HashMap<String, Arc<dyn CustomOperator>>,
114        secrets: &Arc<Secrets>,
115        _ops_budget: Option<u64>,
116    ) -> Self {
117        let mut builder = datalogic_engine_builder();
118        // Deliberately *not* inside `datalogic_engine_builder`: that helper is
119        // the chokepoint for settings which change what an expression *means*,
120        // and a resource ceiling does not. This is the only site that builds an
121        // engine a host's messages run against, so it is where the ceiling
122        // goes — the test constructions have no opinion about it.
123        //
124        // `with_config` replaces the whole `EvaluationConfig`, and `default()`
125        // is exactly what the datalogic builder already holds (its own
126        // `ops_budget` default is `None`), so this is a no-op when unset and
127        // needs no branch of its own. A second knob of this class — upstream's
128        // `max_recursion_depth`, say — should turn this into one carried
129        // `EvaluationConfig` rather than another parallel field.
130        #[cfg(feature = "budget")]
131        {
132            builder = builder.with_config(
133                datalogic_rs::EvaluationConfig::default().with_ops_budget(_ops_budget),
134            );
135        }
136        for (name, op) in operators {
137            builder = builder.add_operator(name.clone(), SharedOperator(Arc::clone(op)));
138        }
139        builder = builder.add_operator(SECRET_OPERATOR, SecretOperator(Arc::clone(secrets)));
140        let engine = Arc::new(builder.build());
141        let template_compiler = TemplateCompiler::new(Arc::clone(&engine));
142        Self {
143            engine,
144            template_compiler,
145        }
146    }
147
148    /// Get the Engine instance
149    pub fn engine(&self) -> Arc<Engine> {
150        Arc::clone(&self.engine)
151    }
152
153    /// Consume the compiler and return the shared engine.
154    pub fn into_engine(self) -> Arc<Engine> {
155        self.engine
156    }
157
158    /// Compile all workflows and their tasks, returning them sorted by priority.
159    /// Returns `Err` on the first validation or compilation failure — engine
160    /// construction is fail-loud so misconfigured workflows can't silently
161    /// disappear at runtime.
162    pub fn compile_workflows(&self, workflows: Vec<Workflow>) -> Result<Vec<Workflow>> {
163        let mut compiled_workflows = Vec::with_capacity(workflows.len());
164
165        for mut workflow in workflows {
166            workflow.validate()?;
167
168            // Populate the cached Arc<str> ids so audit emission can refcount-bump
169            // rather than reallocate per AuditTrail entry.
170            workflow.id_arc = Arc::from(workflow.id.as_str());
171            for task in workflow.all_tasks_mut() {
172                task.id_arc = Arc::from(task.id.as_str());
173            }
174
175            // Pre-split the loop's `temp_data` slots so an iteration never
176            // re-splits a path, and compile `over` once — it is evaluated once
177            // per message, after setup.
178            if let Some(loop_config) = workflow.loop_config.as_mut() {
179                loop_config.precompute_paths();
180                if let Some(over) = &loop_config.over {
181                    let label = format!("workflow {} loop.over", workflow.id);
182                    loop_config.compiled_over = Some(self.compile(over, &label)?);
183                }
184            }
185
186            // Compile the workflow condition (defaults to `true`, which folds
187            // to `None` so the hot path skips the eval — see `compile_condition`).
188            let label = format!("workflow {} condition", workflow.id);
189            workflow.compiled_condition = self.compile_condition(&workflow.condition, &label)?;
190            debug!("Workflow {} condition compiled", workflow.id);
191
192            // Compile task conditions and function-specific logic.
193            self.compile_workflow_tasks(&mut workflow)?;
194
195            // Stamp whether every task is a synchronous built-in. A fully-sync
196            // workflow can be folded into a shared cross-workflow `with_arena`
197            // scope (no `.await`), so the message context is deep-walked into
198            // the arena once per *run* of consecutive fully-sync workflows
199            // instead of once per workflow. Any async/custom task forces the
200            // per-workflow `.await` path.
201            // Setup steps count: "every task" means every task the workflow
202            // can run. A looping workflow never joins the shared run anyway
203            // (`joins_sync_run`), but the flag must not lie about it.
204            let fully_sync = workflow.all_tasks().all(|t| t.function.is_sync_builtin());
205            workflow.fully_sync = fully_sync;
206
207            compiled_workflows.push(workflow);
208        }
209
210        // Sort by priority once at construction time
211        compiled_workflows.sort_by_key(|w| w.priority);
212        Ok(compiled_workflows)
213    }
214
215    /// Compile task conditions and function logic for a workflow
216    fn compile_workflow_tasks(&self, workflow: &mut Workflow) -> Result<()> {
217        // Cloned once: `all_tasks_mut` borrows the whole workflow, and the id
218        // is only needed for labels.
219        let workflow_id = workflow.id.clone();
220        // A loop's setup steps first, then the body — compiled identically, so
221        // a setup step folds, fails and precompiles exactly as a body step.
222        for task in workflow.all_tasks_mut() {
223            // Groups opening at this task, outermost first. Compiled here so a
224            // group condition folds the literal `true` to `None` exactly like a
225            // task condition, and so a malformed one fails at build time.
226            for group in &mut task.group_starts {
227                let label = format!("group {} condition (workflow {workflow_id})", group.id);
228                group.compiled_condition = self.compile_condition(&group.condition, &label)?;
229            }
230
231            let label = format!("task {} condition (workflow {workflow_id})", task.id);
232            task.compiled_condition = self.compile_condition(&task.condition, &label)?;
233
234            // A fan-out's `over` is compiled once, like a condition, and its
235            // slots are pre-split so no call re-splits a path.
236            if let Some(for_each) = task.for_each.as_mut() {
237                for_each.precompute_paths();
238                let label = format!("task {} for_each.over (workflow {workflow_id})", task.id);
239                for_each.compiled_over = Some(self.compile(&for_each.over, &label)?);
240            }
241
242            // Compile function-specific logic (map transformations, validation rules, …)
243            self.compile_function_logic(&mut task.function, &task.id, &workflow_id)?;
244        }
245        Ok(())
246    }
247
248    /// Compile function-specific logic based on function type
249    fn compile_function_logic(
250        &self,
251        function: &mut FunctionConfig,
252        task_id: &str,
253        workflow_id: &str,
254    ) -> Result<()> {
255        match function {
256            FunctionConfig::Map { input, .. } => {
257                self.compile_map_logic(input, task_id, workflow_id)
258            }
259            FunctionConfig::Validation { input, .. } => {
260                self.compile_validation_logic(input, task_id, workflow_id)
261            }
262            FunctionConfig::Filter { input, .. } => {
263                self.compile_filter_logic(input, task_id, workflow_id)
264            }
265            FunctionConfig::Log { input, .. } => {
266                self.compile_log_logic(input, task_id, workflow_id)
267            }
268            FunctionConfig::HttpCall { input, .. } => {
269                self.compile_http_call_logic(input, task_id, workflow_id)
270            }
271            FunctionConfig::Enrich { input, .. } => {
272                self.compile_enrich_logic(input, task_id, workflow_id)
273            }
274            FunctionConfig::PublishKafka { input, .. } => {
275                self.compile_publish_kafka_logic(input, task_id, workflow_id)
276            }
277            FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
278                self.compile_parse_logic(input, task_id, workflow_id)
279            }
280            FunctionConfig::PublishJson { input, .. }
281            | FunctionConfig::PublishXml { input, .. } => {
282                self.compile_publish_logic(input, task_id, workflow_id)
283            }
284            // Custom and other functions don't need pre-compilation
285            _ => Ok(()),
286        }
287    }
288
289    /// Compile a JSONLogic expression and return the `Arc<Logic>`. Errors are
290    /// surfaced as `DataflowError::LogicEvaluation` with the supplied
291    /// context label for debugging.
292    fn compile(&self, logic: &Value, ctx_label: &str) -> Result<Arc<Logic>> {
293        self.engine
294            .compile_arc(logic)
295            .map_err(|e| DataflowError::LogicEvaluation(format!("{}: {}", ctx_label, e)))
296    }
297
298    /// Compile a workflow/task *condition*, returning `None` when the source is
299    /// the literal `true`. A `None` condition is treated as "always run" by
300    /// `evaluate_condition` / `evaluate_condition_in_arena`, so the hot path
301    /// skips the `engine.evaluate` call — and, in the sync stretch, the
302    /// per-task arena context slice build — entirely for the overwhelmingly
303    /// common default `condition: true`. datalogic already folds a literal
304    /// `true` to a near-free literal-fast-path eval; this avoids even setting
305    /// up the call. Non-literal conditions (including `false` and any real
306    /// expression) compile as normal.
307    fn compile_condition(&self, condition: &Value, ctx_label: &str) -> Result<Option<Arc<Logic>>> {
308        if matches!(condition, Value::Bool(true)) {
309            return Ok(None);
310        }
311        Ok(Some(self.compile(condition, ctx_label)?))
312    }
313
314    /// Compile the `parse_json` / `parse_xml` parameters. A literal `target`
315    /// folds here and keeps the precomputed `data.{target}` write path the hot
316    /// path has always used.
317    fn compile_parse_logic(
318        &self,
319        config: &mut ParseConfig,
320        task_id: &str,
321        workflow_id: &str,
322    ) -> Result<()> {
323        self.compile_template(&mut config.source, "parse source", task_id, workflow_id)?;
324        config.target.compile(
325            &self.template_compiler,
326            &label("parse target", task_id, workflow_id),
327        )
328    }
329
330    /// Compile the `publish_json` / `publish_xml` parameters.
331    fn compile_publish_logic(
332        &self,
333        config: &mut PublishConfig,
334        task_id: &str,
335        workflow_id: &str,
336    ) -> Result<()> {
337        self.compile_template(&mut config.source, "publish source", task_id, workflow_id)?;
338        self.compile_template(
339            &mut config.root_element,
340            "publish root_element",
341            task_id,
342            workflow_id,
343        )?;
344        config.target.compile(
345            &self.template_compiler,
346            &label("publish target", task_id, workflow_id),
347        )
348    }
349
350    /// Compile map transformation logic
351    fn compile_map_logic(
352        &self,
353        config: &mut MapConfig,
354        task_id: &str,
355        workflow_id: &str,
356    ) -> Result<()> {
357        for mapping in &mut config.mappings {
358            // The destination. A literal folds to a constant here and keeps its
359            // `(dotted, parts)` pair precomputed, so the hot loop never
360            // re-splits — the same guarantee the old hand-rolled split gave,
361            // now with a computed destination possible alongside it.
362            let path_label = format!("map path for task {task_id} in workflow {workflow_id}");
363            mapping.path.compile(&self.template_compiler, &path_label)?;
364
365            // A removal has no logic to compile; `execute` never reads the slot.
366            if mapping.unset {
367                continue;
368            }
369
370            let label = format!(
371                "map logic for task {} in workflow {} (path {})",
372                task_id,
373                workflow_id,
374                mapping.describe_path()
375            );
376            mapping.compiled_logic = Some(self.compile(&mapping.logic, &label)?);
377        }
378        Ok(())
379    }
380
381    /// Compile validation rule logic
382    fn compile_validation_logic(
383        &self,
384        config: &mut ValidationConfig,
385        task_id: &str,
386        workflow_id: &str,
387    ) -> Result<()> {
388        for (idx, rule) in config.rules.iter_mut().enumerate() {
389            let label = format!(
390                "validation rule {} for task {} in workflow {}",
391                idx, task_id, workflow_id
392            );
393            rule.compiled_logic = Some(self.compile(&rule.logic, &label)?);
394
395            let message_label = format!(
396                "validation rule {idx} message for task {task_id} in workflow {workflow_id}"
397            );
398            rule.message
399                .compile(&self.template_compiler, &message_label)?;
400        }
401        Ok(())
402    }
403
404    /// Compile log message and field expressions
405    fn compile_log_logic(
406        &self,
407        config: &mut LogConfig,
408        task_id: &str,
409        workflow_id: &str,
410    ) -> Result<()> {
411        let msg_label = label("log message", task_id, workflow_id);
412        config.compiled_message = Some(self.compile(&config.message, &msg_label)?);
413
414        // Compile each field expression. Collect into a fresh Vec, then
415        // assign — keeps the immutable borrow of `config.fields` from
416        // overlapping with the mutable borrow of `config.compiled_fields`.
417        // Sorted, because `fields` is a `HashMap` and this Vec is the order the
418        // fields are emitted in: unsorted, a log line's field order — and which
419        // field a compile error names first — varies per process.
420        let mut keys: Vec<&String> = config.fields.keys().collect();
421        keys.sort_unstable();
422        let mut compiled_fields = Vec::with_capacity(config.fields.len());
423        for key in keys {
424            let label = format!(
425                "log field '{}' for task {} in workflow {}",
426                key, task_id, workflow_id
427            );
428            compiled_fields.push((
429                key.clone(),
430                Some(self.compile(&config.fields[key], &label)?),
431            ));
432        }
433        config.compiled_fields = compiled_fields;
434        Ok(())
435    }
436
437    /// Compile filter condition logic
438    fn compile_filter_logic(
439        &self,
440        config: &mut FilterConfig,
441        task_id: &str,
442        workflow_id: &str,
443    ) -> Result<()> {
444        let label = label("filter condition", task_id, workflow_id);
445        config.compiled_condition = Some(self.compile(&config.condition, &label)?);
446        Ok(())
447    }
448
449    /// Compile every `http_call` parameter.
450    fn compile_http_call_logic(
451        &self,
452        config: &mut HttpCallConfig,
453        task_id: &str,
454        workflow_id: &str,
455    ) -> Result<()> {
456        self.compile_template(
457            &mut config.connector,
458            "http_call connector",
459            task_id,
460            workflow_id,
461        )?;
462        self.compile_template(
463            &mut config.timeout_ms,
464            "http_call timeout_ms",
465            task_id,
466            workflow_id,
467        )?;
468        for (name, value) in &mut config.headers {
469            let what = format!("http_call header {name}");
470            self.compile_template(value, &what, task_id, workflow_id)?;
471        }
472        for (what, field) in [
473            ("http_call path", &mut config.path),
474            ("http_call body", &mut config.body),
475            ("http_call body_format", &mut config.body_format),
476            ("http_call response_path", &mut config.response_path),
477            ("http_call response_format", &mut config.response_format),
478        ] {
479            self.compile_template_field(field, what, task_id, workflow_id)?;
480        }
481        Ok(())
482    }
483
484    /// Compile every `enrich` parameter.
485    fn compile_enrich_logic(
486        &self,
487        config: &mut EnrichConfig,
488        task_id: &str,
489        workflow_id: &str,
490    ) -> Result<()> {
491        self.compile_template(
492            &mut config.connector,
493            "enrich connector",
494            task_id,
495            workflow_id,
496        )?;
497        self.compile_template(
498            &mut config.merge_path,
499            "enrich merge_path",
500            task_id,
501            workflow_id,
502        )?;
503        self.compile_template(
504            &mut config.timeout_ms,
505            "enrich timeout_ms",
506            task_id,
507            workflow_id,
508        )?;
509        self.compile_template_field(&mut config.path, "enrich path", task_id, workflow_id)
510    }
511
512    /// Compile every `publish_kafka` parameter.
513    fn compile_publish_kafka_logic(
514        &self,
515        config: &mut PublishKafkaConfig,
516        task_id: &str,
517        workflow_id: &str,
518    ) -> Result<()> {
519        self.compile_template(
520            &mut config.connector,
521            "publish_kafka connector",
522            task_id,
523            workflow_id,
524        )?;
525        self.compile_template(
526            &mut config.topic,
527            "publish_kafka topic",
528            task_id,
529            workflow_id,
530        )?;
531        self.compile_template_field(&mut config.key, "publish_kafka key", task_id, workflow_id)?;
532        self.compile_template_field(
533            &mut config.value,
534            "publish_kafka value",
535            task_id,
536            workflow_id,
537        )
538    }
539
540    /// Compile a required `Template` parameter against `self.template_compiler`.
541    /// `what` labels the compile-error context as `"{what} for task {task_id}
542    /// in workflow {workflow_id}"`, e.g. `"http_call connector"`.
543    fn compile_template(
544        &self,
545        field: &mut Template,
546        what: &str,
547        task_id: &str,
548        workflow_id: &str,
549    ) -> Result<()> {
550        field.compile(&self.template_compiler, &label(what, task_id, workflow_id))
551    }
552
553    /// Compile an optional built-in integration `Template` field — `path_logic`,
554    /// against `self.template_compiler`.
555    /// A `None` field is a no-op, matching every one of these fields being
556    /// optional. `what` labels the compile-error context as `"{what} for task
557    /// {task_id} in workflow {workflow_id}"`, e.g. `"http_call body"`.
558    fn compile_template_field(
559        &self,
560        field: &mut Option<Template>,
561        what: &str,
562        task_id: &str,
563        workflow_id: &str,
564    ) -> Result<()> {
565        if let Some(t) = field {
566            t.compile(&self.template_compiler, &label(what, task_id, workflow_id))?;
567        }
568        Ok(())
569    }
570}
571
572/// Format a JSONLogic compile-error label as `"{what} for task {task_id} in
573/// workflow {workflow_id}"` — the shape shared by every built-in whose
574/// context needs no further detail (a few, like map mappings and validation
575/// rules, append per-item detail and format their own label instead).
576fn label(what: &str, task_id: &str, workflow_id: &str) -> String {
577    format!("{what} for task {task_id} in workflow {workflow_id}")
578}
579
580#[cfg(test)]
581mod tests {
582    //! Pins the datalogic operator semantics this crate's own behaviour
583    //! depends on. Not an attempt at a general operator-semantics table — that
584    //! was investigated and refused: `datalogic-rs` keeps `mod opcode;` private
585    //! and `OpCode` `pub(crate)`, so this crate could only hand-maintain the
586    //! same unverified table one layer lower, and it would actively mislead —
587    //! see `an_unrecognised_operator_is_not_an_error_under_templating` below,
588    //! which is exactly the case a static "known operators" table would get
589    //! wrong. Every value here was read from a live `datalogic_rs::Engine`
590    //! built the way `LogicCompiler::new` builds one, not assumed.
591    //!
592    //! If a `datalogic-rs` upgrade changes any of these, that is a real
593    //! behaviour change for every workflow in production — these tests exist
594    //! so it fails CI instead of surfacing as a support ticket.
595    //!
596    //! These values are also *feature*-dependent. This crate exposes the
597    //! `datalogic-rs` operator families as cargo features, all off by default.
598    //! Any test whose answer changes when a family is enabled carries a
599    //! `#[cfg(feature = ...)]` so **both** configurations stay pinned —
600    //! otherwise the `--all-features` CI run would be the only one checking
601    //! anything and the default build, which is what `cargo add dataflow-rs`
602    //! delivers, would go untested.
603
604    use super::*;
605    use serde_json::json;
606
607    /// The exact engine construction `LogicCompiler::new` uses: templating
608    /// enabled, plus whichever `datalogic-rs` operator families this crate's
609    /// cargo features turned on — none, by default. Which families are live is
610    /// fixed at compile time, so a test whose result depends on one must be
611    /// `#[cfg]`-gated rather than assuming the default build.
612    fn engine() -> Engine {
613        crate::engine::compiler::datalogic_engine_builder().build()
614    }
615
616    fn eval(engine: &Engine, logic: &Value) -> Value {
617        let compiled = engine.compile_arc(logic).expect("should compile");
618        let ctx = datavalue::OwnedDataValue::from(&json!({}));
619        serde_json::from_str(
620            &engine
621                .session()
622                .eval_str(&compiled, &ctx)
623                .expect("should evaluate"),
624        )
625        .expect("eval_str output should be valid JSON")
626    }
627
628    /// A one-task workflow carrying `extra` as additional top-level JSON keys.
629    fn workflow_json(extra: &str) -> String {
630        format!(
631            r#"{{ "id": "w", "name": "w", {extra}
632                 "tasks": [{{"id": "t", "name": "t",
633                             "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
634        )
635    }
636
637    #[test]
638    fn compile_workflows_precomputes_the_loop_counter_path() {
639        let workflow =
640            Workflow::from_json(&workflow_json(r#""loop": {"counter": "i", "max": 3},"#))
641                .expect("should parse");
642
643        let compiled = LogicCompiler::new()
644            .compile_workflows(vec![workflow])
645            .expect("should compile");
646
647        let cfg = compiled[0].loop_config.as_ref().expect("loop config");
648        let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
649        assert_eq!(parts, ["temp_data", "i"]);
650    }
651
652    #[test]
653    fn compile_workflows_compiles_setup_steps_and_over() {
654        let workflow = Workflow::from_json(
655            r#"{ "id": "w", "name": "w",
656                 "loop": {"counter": "i", "max": 8, "as": "item", "scratch": "it",
657                          "over": {"var": "temp_data.batch"},
658                          "setup": [
659                            {"id": "g", "condition": {"var": "data.go"}, "tasks": [
660                              {"id": "read", "name": "read",
661                               "condition": {"var": "data.ready"},
662                               "function": {"name": "map", "input": {"mappings": [
663                                 {"path": "temp_data.batch", "logic": {"var": "data.items"}}]}}}]}]},
664                 "tasks": [{"id": "t", "name": "t",
665                   "function": {"name": "map", "input": {"mappings": []}}}] }"#,
666        )
667        .unwrap();
668        let compiled = LogicCompiler::new()
669            .compile_workflows(vec![workflow])
670            .expect("compiles");
671        let cfg = compiled[0].loop_config.as_ref().unwrap();
672
673        assert!(
674            cfg.compiled_over.is_some(),
675            "over is compiled once at build"
676        );
677        let parts: Vec<&str> = cfg.item_parts.iter().map(Arc::as_ref).collect();
678        assert_eq!(parts, ["temp_data", "item"]);
679        assert_eq!(
680            cfg.setup[0].id_arc.as_ref(),
681            "read",
682            "setup ids are interned"
683        );
684        assert!(
685            cfg.setup[0].compiled_condition.is_some(),
686            "a non-trivial setup condition compiles"
687        );
688        assert!(
689            cfg.setup[0].group_starts[0].compiled_condition.is_some(),
690            "and so does its group's"
691        );
692        assert!(compiled[0].fully_sync, "map-only setup and body");
693    }
694
695    #[test]
696    fn compile_workflows_compiles_a_for_each() {
697        let wf = Workflow::from_json(
698            r#"{ "id": "w", "name": "w", "tasks": [
699                 {"id": "t", "name": "t",
700                  "for_each": {"over": {"var": "data.ps"}, "as": "p",
701                               "collect": "temp_data.m", "into": "temp_data.ms"},
702                  "function": {"name": "infer", "input": {}}}] }"#,
703        )
704        .unwrap();
705        let compiled = LogicCompiler::new().compile_workflows(vec![wf]).unwrap();
706        let fe = compiled[0].tasks[0].for_each.as_ref().unwrap();
707        assert!(fe.compiled_over.is_some(), "over is compiled once at build");
708        let parts: Vec<&str> = fe.index_parts.iter().map(Arc::as_ref).collect();
709        assert_eq!(parts, ["temp_data", "p_index"]);
710        assert_eq!(fe.into_parts.len(), 2);
711        assert!(
712            !compiled[0].fully_sync,
713            "a fan-out is always handler-backed"
714        );
715    }
716
717    #[test]
718    fn compile_workflows_rejects_an_invalid_loop_config() {
719        // `Workflow::validate` runs inside `compile_workflows`, so a bound that
720        // could never advance fails engine construction rather than the first
721        // message.
722        let workflow =
723            Workflow::from_json(&workflow_json(r#""loop": {"init": 5, "max": 5},"#)).unwrap();
724
725        assert!(
726            LogicCompiler::new()
727                .compile_workflows(vec![workflow])
728                .is_err()
729        );
730    }
731
732    #[test]
733    fn compile_workflows_leaves_a_non_looping_workflow_without_a_loop() {
734        let workflow = Workflow::from_json(&workflow_json("")).expect("should parse");
735
736        let compiled = LogicCompiler::new()
737            .compile_workflows(vec![workflow])
738            .expect("should compile");
739
740        assert!(compiled[0].loop_config.is_none());
741    }
742
743    #[test]
744    fn empty_operand_results_this_crate_would_silently_break_on() {
745        // A workflow author can write any of these — a map mapping folding an
746        // empty list, a filter condition over an empty selector — and the
747        // crate never validates operand count. If a datalogic upgrade changed
748        // any of these defaults, every workflow relying on the vacuous case
749        // would silently start producing a different value.
750        let e = engine();
751        for (logic, expected) in [
752            (json!({"and": []}), json!(null)),
753            (json!({"or": []}), json!(null)),
754            (json!({"+": []}), json!(0)),
755            (json!({"*": []}), json!(1)),
756            (json!({"cat": []}), json!("")),
757            (json!({"merge": []}), json!([])),
758            (json!({"missing": []}), json!([])),
759        ] {
760            assert_eq!(eval(&e, &logic), expected, "for {logic}");
761        }
762    }
763
764    #[test]
765    fn a_missing_var_path_resolves_to_null_not_an_error() {
766        // The exact mechanism behind the pitfall CLAUDE.md documents for
767        // `payload.*` expressions: a `var` over a path that does not resolve
768        // is `Null`, silently, never `Err`. `Template::eval` and the built-in
769        // `*_logic` fields inherit this — there is no engine-level signal that
770        // distinguishes "field absent" from "field is null".
771        let e = engine();
772        assert_eq!(
773            eval(&e, &json!({"var": "data.does_not_exist"})),
774            json!(null)
775        );
776    }
777
778    #[test]
779    fn truthy_falsy_matches_the_documented_semantics() {
780        // Verifies the claim in docs/src/advanced/jsonlogic.md's Truthy/Falsy
781        // section, which is a `json` fence and therefore NOT compiled by
782        // dataflow-docs-tests — this is the only check on that claim.
783        // Notable and easy to get wrong: an empty object `{}` is falsy here,
784        // unlike some JSONLogic implementations that treat any object as truthy.
785        let e = engine();
786        for (v, truthy) in [
787            (json!(0), false),
788            (json!(""), false),
789            (json!(false), false),
790            (json!(null), false),
791            (json!([]), false),
792            (json!({}), false),
793            (json!("x"), true),
794            (json!(1), true),
795        ] {
796            assert_eq!(
797                eval(&e, &json!({"!!": v})),
798                json!(truthy),
799                "truthiness of {v}"
800            );
801        }
802    }
803
804    #[test]
805    fn an_unrecognised_operator_is_not_an_error_under_templating() {
806        // The load-bearing fact behind #26's refusal of a static "known
807        // operators" table, and the reason `Template` documents itself as
808        // opt-in per field rather than a blanket JSON wrapper: under
809        // templating (which LogicCompiler and TemplateCompiler both enable),
810        // an outright typo neither fails to compile nor fails to evaluate. It
811        // echoes back as a literal structured object instead — a workflow
812        // author who mistypes an operator name gets silent pass-through, not a
813        // validation error. True under every feature combination, so this half
814        // of the tripwire is unconditional.
815        let e = engine();
816        let logic = json!({"totally_made_up_op_xyz": ["a", "b"]});
817        assert_eq!(
818            eval(&e, &logic),
819            logic,
820            "an unrecognised operator must echo back verbatim, not error"
821        );
822    }
823
824    /// With `ext-string` off, `starts_with` is not a name the engine knows, so
825    /// it is indistinguishable from the typo above: silent pass-through. This
826    /// is the failure mode a workflow author hits when they reach for an
827    /// operator whose family this build did not enable — no error, just a
828    /// wrong value.
829    #[cfg(not(feature = "ext-string"))]
830    #[test]
831    fn a_gated_operator_echoes_back_while_its_family_is_off() {
832        let e = engine();
833        let logic = json!({"starts_with": ["hello", "he"]});
834        assert_eq!(
835            eval(&e, &logic),
836            logic,
837            "an operator behind an unenabled family must echo back, not error"
838        );
839    }
840
841    /// The other side of the same coin, and the reason enabling a family is
842    /// not a no-op for existing workflows: `ext-string` converts a previously
843    /// inert `{"starts_with": [...]}` *literal* into a live operator call.
844    /// Anyone carrying such an object as data through a `map` mapping sees
845    /// their value silently replaced by the operator's result.
846    #[cfg(feature = "ext-string")]
847    #[test]
848    fn a_gated_operator_evaluates_once_its_family_is_on() {
849        let e = engine();
850        assert_eq!(
851            eval(&e, &json!({"starts_with": ["hello", "he"]})),
852            json!(true),
853            "with ext-string on, starts_with must evaluate, not echo"
854        );
855    }
856
857    /// `datetime` is the one family that is not confined to new operator
858    /// names. `datalogic-rs`'s comparison path probes *plain strings* for a
859    /// datetime/duration shape before falling back to byte comparison, so
860    /// `==` and the ordering operators change answers on date-shaped
861    /// operands. These two strings are different byte sequences naming the
862    /// same instant.
863    #[test]
864    fn datetime_feature_changes_plain_string_comparison() {
865        let e = engine();
866        let logic = json!({"==": ["2024-01-15T00:00:00Z", "2024-01-15T01:00:00+01:00"]});
867        #[cfg(feature = "datetime")]
868        assert_eq!(eval(&e, &logic), json!(true));
869        #[cfg(not(feature = "datetime"))]
870        assert_eq!(eval(&e, &logic), json!(false));
871    }
872
873    /// Each family's cargo feature actually reaches `datalogic-rs`. One
874    /// representative operator per family is enough — the feature either
875    /// forwards or it does not.
876    #[cfg(feature = "ext-string")]
877    #[test]
878    fn ext_string_feature_reaches_datalogic() {
879        let e = engine();
880        assert_eq!(eval(&e, &json!({"upper": "ab"})), json!("AB"));
881    }
882
883    #[cfg(feature = "ext-array")]
884    #[test]
885    fn ext_array_feature_reaches_datalogic() {
886        let e = engine();
887        assert_eq!(eval(&e, &json!({"sort": [[3, 1, 2]]})), json!([1, 2, 3]));
888    }
889
890    #[cfg(feature = "ext-math")]
891    #[test]
892    fn ext_math_feature_reaches_datalogic() {
893        let e = engine();
894        assert_eq!(eval(&e, &json!({"abs": -5})), json!(5));
895    }
896
897    #[cfg(feature = "ext-control")]
898    #[test]
899    fn ext_control_feature_reaches_datalogic() {
900        let e = engine();
901        assert_eq!(
902            eval(&e, &json!({"??": [null, "fallback"]})),
903            json!("fallback")
904        );
905    }
906
907    #[cfg(feature = "ext-object")]
908    #[test]
909    fn ext_object_feature_reaches_datalogic() {
910        let e = engine();
911        assert_eq!(
912            eval(&e, &json!({"keys": [{"a": 1, "b": 2}]})),
913            json!(["a", "b"])
914        );
915    }
916
917    #[cfg(feature = "error-handling")]
918    #[test]
919    fn error_handling_feature_reaches_datalogic() {
920        // `error-handling` is the JSONLogic `try`/`throw` pair — unrelated to
921        // this crate's own always-on error handling.
922        let e = engine();
923        assert_eq!(
924            eval(&e, &json!({"try": [{"throw": "boom"}, "recovered"]})),
925            json!("recovered")
926        );
927    }
928
929    /// The `datetime` family's own operators. Their exact output depends on
930    /// the ambient clock and on format details this crate does not pin, so
931    /// assert only the property the feature actually buys: the operator is
932    /// recognised and evaluates, rather than echoing back as a literal.
933    #[cfg(feature = "datetime")]
934    #[test]
935    fn datetime_feature_reaches_datalogic() {
936        let e = engine();
937        let logic = json!({"now": []});
938        let result = eval(&e, &logic);
939        assert_ne!(result, logic, "with datetime on, `now` must not echo back");
940        assert!(!result.is_null(), "`now` should produce a value, got null");
941    }
942}