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 use super::*;
368 use serde_json::json;
369
370 /// The exact engine construction `LogicCompiler::new` uses: templating
371 /// enabled, `serde_json` feature only — no `ext-string`/`ext-array`/
372 /// `ext-math`/`ext-control`/`error-handling`/`datetime`.
373 fn engine() -> Engine {
374 Engine::builder().with_templating(true).build()
375 }
376
377 fn eval(engine: &Engine, logic: &Value) -> Value {
378 let compiled = engine.compile_arc(logic).expect("should compile");
379 let ctx = datavalue::OwnedDataValue::from(&json!({}));
380 serde_json::from_str(
381 &engine
382 .session()
383 .eval_str(&compiled, &ctx)
384 .expect("should evaluate"),
385 )
386 .expect("eval_str output should be valid JSON")
387 }
388
389 #[test]
390 fn empty_operand_results_this_crate_would_silently_break_on() {
391 // A workflow author can write any of these — a map mapping folding an
392 // empty list, a filter condition over an empty selector — and the
393 // crate never validates operand count. If a datalogic upgrade changed
394 // any of these defaults, every workflow relying on the vacuous case
395 // would silently start producing a different value.
396 let e = engine();
397 for (logic, expected) in [
398 (json!({"and": []}), json!(null)),
399 (json!({"or": []}), json!(null)),
400 (json!({"+": []}), json!(0)),
401 (json!({"*": []}), json!(1)),
402 (json!({"cat": []}), json!("")),
403 (json!({"merge": []}), json!([])),
404 (json!({"missing": []}), json!([])),
405 ] {
406 assert_eq!(eval(&e, &logic), expected, "for {logic}");
407 }
408 }
409
410 #[test]
411 fn a_missing_var_path_resolves_to_null_not_an_error() {
412 // The exact mechanism behind the pitfall CLAUDE.md documents for
413 // `payload.*` expressions: a `var` over a path that does not resolve
414 // is `Null`, silently, never `Err`. `Template::eval` and the built-in
415 // `*_logic` fields inherit this — there is no engine-level signal that
416 // distinguishes "field absent" from "field is null".
417 let e = engine();
418 assert_eq!(
419 eval(&e, &json!({"var": "data.does_not_exist"})),
420 json!(null)
421 );
422 }
423
424 #[test]
425 fn truthy_falsy_matches_the_documented_semantics() {
426 // Verifies the claim in docs/src/advanced/jsonlogic.md's Truthy/Falsy
427 // section, which is a `json` fence and therefore NOT compiled by
428 // dataflow-docs-tests — this is the only check on that claim.
429 // Notable and easy to get wrong: an empty object `{}` is falsy here,
430 // unlike some JSONLogic implementations that treat any object as truthy.
431 let e = engine();
432 for (v, truthy) in [
433 (json!(0), false),
434 (json!(""), false),
435 (json!(false), false),
436 (json!(null), false),
437 (json!([]), false),
438 (json!({}), false),
439 (json!("x"), true),
440 (json!(1), true),
441 ] {
442 assert_eq!(
443 eval(&e, &json!({"!!": v})),
444 json!(truthy),
445 "truthiness of {v}"
446 );
447 }
448 }
449
450 #[test]
451 fn an_unrecognised_operator_is_not_an_error_under_templating() {
452 // The load-bearing fact behind #26's refusal of a static "known
453 // operators" table, and the reason `Template` documents itself as
454 // opt-in per field rather than a blanket JSON wrapper: under
455 // templating (which LogicCompiler and TemplateCompiler both enable),
456 // neither a name gated behind an unenabled feature (`starts_with`
457 // needs `ext-string`, not in this crate's Cargo.toml) nor an outright
458 // typo fails to compile or fails to evaluate. Both echo back as a
459 // literal structured object instead — a workflow author who mistypes
460 // an operator name gets silent pass-through, not a validation error.
461 let e = engine();
462 for logic in [
463 json!({"starts_with": ["hello", "he"]}),
464 json!({"totally_made_up_op_xyz": ["a", "b"]}),
465 ] {
466 assert_eq!(
467 eval(&e, &logic),
468 logic,
469 "an unrecognised/gated operator must echo back verbatim, not error"
470 );
471 }
472 }
473}