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 &mut workflow.tasks {
172 task.id_arc = Arc::from(task.id.as_str());
173 }
174
175 // Pre-split `temp_data.{counter}` so a loop sweep never re-splits
176 // the write path.
177 if let Some(loop_config) = workflow.loop_config.as_mut() {
178 loop_config.precompute_counter_path();
179 }
180
181 // Compile the workflow condition (defaults to `true`, which folds
182 // to `None` so the hot path skips the eval — see `compile_condition`).
183 let label = format!("workflow {} condition", workflow.id);
184 workflow.compiled_condition = self.compile_condition(&workflow.condition, &label)?;
185 debug!("Workflow {} condition compiled", workflow.id);
186
187 // Compile task conditions and function-specific logic.
188 self.compile_workflow_tasks(&mut workflow)?;
189
190 // Stamp whether every task is a synchronous built-in. A fully-sync
191 // workflow can be folded into a shared cross-workflow `with_arena`
192 // scope (no `.await`), so the message context is deep-walked into
193 // the arena once per *run* of consecutive fully-sync workflows
194 // instead of once per workflow. Any async/custom task forces the
195 // per-workflow `.await` path.
196 workflow.fully_sync = workflow.tasks.iter().all(|t| t.function.is_sync_builtin());
197
198 compiled_workflows.push(workflow);
199 }
200
201 // Sort by priority once at construction time
202 compiled_workflows.sort_by_key(|w| w.priority);
203 Ok(compiled_workflows)
204 }
205
206 /// Compile task conditions and function logic for a workflow
207 fn compile_workflow_tasks(&self, workflow: &mut Workflow) -> Result<()> {
208 for task in &mut workflow.tasks {
209 // Groups opening at this task, outermost first. Compiled here so a
210 // group condition folds the literal `true` to `None` exactly like a
211 // task condition, and so a malformed one fails at build time.
212 for group in &mut task.group_starts {
213 let label = format!("group {} condition (workflow {})", group.id, workflow.id);
214 group.compiled_condition = self.compile_condition(&group.condition, &label)?;
215 }
216
217 let label = format!("task {} condition (workflow {})", task.id, workflow.id);
218 task.compiled_condition = self.compile_condition(&task.condition, &label)?;
219
220 // Compile function-specific logic (map transformations, validation rules, …)
221 self.compile_function_logic(&mut task.function, &task.id, &workflow.id)?;
222 }
223 Ok(())
224 }
225
226 /// Compile function-specific logic based on function type
227 fn compile_function_logic(
228 &self,
229 function: &mut FunctionConfig,
230 task_id: &str,
231 workflow_id: &str,
232 ) -> Result<()> {
233 match function {
234 FunctionConfig::Map { input, .. } => {
235 self.compile_map_logic(input, task_id, workflow_id)
236 }
237 FunctionConfig::Validation { input, .. } => {
238 self.compile_validation_logic(input, task_id, workflow_id)
239 }
240 FunctionConfig::Filter { input, .. } => {
241 self.compile_filter_logic(input, task_id, workflow_id)
242 }
243 FunctionConfig::Log { input, .. } => {
244 self.compile_log_logic(input, task_id, workflow_id)
245 }
246 FunctionConfig::HttpCall { input, .. } => {
247 self.compile_http_call_logic(input, task_id, workflow_id)
248 }
249 FunctionConfig::Enrich { input, .. } => {
250 self.compile_enrich_logic(input, task_id, workflow_id)
251 }
252 FunctionConfig::PublishKafka { input, .. } => {
253 self.compile_publish_kafka_logic(input, task_id, workflow_id)
254 }
255 FunctionConfig::ParseJson { input, .. } | FunctionConfig::ParseXml { input, .. } => {
256 self.compile_parse_logic(input, task_id, workflow_id)
257 }
258 FunctionConfig::PublishJson { input, .. }
259 | FunctionConfig::PublishXml { input, .. } => {
260 self.compile_publish_logic(input, task_id, workflow_id)
261 }
262 // Custom and other functions don't need pre-compilation
263 _ => Ok(()),
264 }
265 }
266
267 /// Compile a JSONLogic expression and return the `Arc<Logic>`. Errors are
268 /// surfaced as `DataflowError::LogicEvaluation` with the supplied
269 /// context label for debugging.
270 fn compile(&self, logic: &Value, ctx_label: &str) -> Result<Arc<Logic>> {
271 self.engine
272 .compile_arc(logic)
273 .map_err(|e| DataflowError::LogicEvaluation(format!("{}: {}", ctx_label, e)))
274 }
275
276 /// Compile a workflow/task *condition*, returning `None` when the source is
277 /// the literal `true`. A `None` condition is treated as "always run" by
278 /// `evaluate_condition` / `evaluate_condition_in_arena`, so the hot path
279 /// skips the `engine.evaluate` call — and, in the sync stretch, the
280 /// per-task arena context slice build — entirely for the overwhelmingly
281 /// common default `condition: true`. datalogic already folds a literal
282 /// `true` to a near-free literal-fast-path eval; this avoids even setting
283 /// up the call. Non-literal conditions (including `false` and any real
284 /// expression) compile as normal.
285 fn compile_condition(&self, condition: &Value, ctx_label: &str) -> Result<Option<Arc<Logic>>> {
286 if matches!(condition, Value::Bool(true)) {
287 return Ok(None);
288 }
289 Ok(Some(self.compile(condition, ctx_label)?))
290 }
291
292 /// Compile the `parse_json` / `parse_xml` parameters. A literal `target`
293 /// folds here and keeps the precomputed `data.{target}` write path the hot
294 /// path has always used.
295 fn compile_parse_logic(
296 &self,
297 config: &mut ParseConfig,
298 task_id: &str,
299 workflow_id: &str,
300 ) -> Result<()> {
301 self.compile_template(&mut config.source, "parse source", task_id, workflow_id)?;
302 config.target.compile(
303 &self.template_compiler,
304 &label("parse target", task_id, workflow_id),
305 )
306 }
307
308 /// Compile the `publish_json` / `publish_xml` parameters.
309 fn compile_publish_logic(
310 &self,
311 config: &mut PublishConfig,
312 task_id: &str,
313 workflow_id: &str,
314 ) -> Result<()> {
315 self.compile_template(&mut config.source, "publish source", task_id, workflow_id)?;
316 self.compile_template(
317 &mut config.root_element,
318 "publish root_element",
319 task_id,
320 workflow_id,
321 )?;
322 config.target.compile(
323 &self.template_compiler,
324 &label("publish target", task_id, workflow_id),
325 )
326 }
327
328 /// Compile map transformation logic
329 fn compile_map_logic(
330 &self,
331 config: &mut MapConfig,
332 task_id: &str,
333 workflow_id: &str,
334 ) -> Result<()> {
335 for mapping in &mut config.mappings {
336 // The destination. A literal folds to a constant here and keeps its
337 // `(dotted, parts)` pair precomputed, so the hot loop never
338 // re-splits — the same guarantee the old hand-rolled split gave,
339 // now with a computed destination possible alongside it.
340 let path_label = format!("map path for task {task_id} in workflow {workflow_id}");
341 mapping.path.compile(&self.template_compiler, &path_label)?;
342
343 let label = format!(
344 "map logic for task {} in workflow {} (path {})",
345 task_id,
346 workflow_id,
347 mapping.describe_path()
348 );
349 mapping.compiled_logic = Some(self.compile(&mapping.logic, &label)?);
350 }
351 Ok(())
352 }
353
354 /// Compile validation rule logic
355 fn compile_validation_logic(
356 &self,
357 config: &mut ValidationConfig,
358 task_id: &str,
359 workflow_id: &str,
360 ) -> Result<()> {
361 for (idx, rule) in config.rules.iter_mut().enumerate() {
362 let label = format!(
363 "validation rule {} for task {} in workflow {}",
364 idx, task_id, workflow_id
365 );
366 rule.compiled_logic = Some(self.compile(&rule.logic, &label)?);
367
368 let message_label = format!(
369 "validation rule {idx} message for task {task_id} in workflow {workflow_id}"
370 );
371 rule.message
372 .compile(&self.template_compiler, &message_label)?;
373 }
374 Ok(())
375 }
376
377 /// Compile log message and field expressions
378 fn compile_log_logic(
379 &self,
380 config: &mut LogConfig,
381 task_id: &str,
382 workflow_id: &str,
383 ) -> Result<()> {
384 let msg_label = label("log message", task_id, workflow_id);
385 config.compiled_message = Some(self.compile(&config.message, &msg_label)?);
386
387 // Compile each field expression. Collect into a fresh Vec, then
388 // assign — keeps the immutable borrow of `config.fields` from
389 // overlapping with the mutable borrow of `config.compiled_fields`.
390 // Sorted, because `fields` is a `HashMap` and this Vec is the order the
391 // fields are emitted in: unsorted, a log line's field order — and which
392 // field a compile error names first — varies per process.
393 let mut keys: Vec<&String> = config.fields.keys().collect();
394 keys.sort_unstable();
395 let mut compiled_fields = Vec::with_capacity(config.fields.len());
396 for key in keys {
397 let label = format!(
398 "log field '{}' for task {} in workflow {}",
399 key, task_id, workflow_id
400 );
401 compiled_fields.push((
402 key.clone(),
403 Some(self.compile(&config.fields[key], &label)?),
404 ));
405 }
406 config.compiled_fields = compiled_fields;
407 Ok(())
408 }
409
410 /// Compile filter condition logic
411 fn compile_filter_logic(
412 &self,
413 config: &mut FilterConfig,
414 task_id: &str,
415 workflow_id: &str,
416 ) -> Result<()> {
417 let label = label("filter condition", task_id, workflow_id);
418 config.compiled_condition = Some(self.compile(&config.condition, &label)?);
419 Ok(())
420 }
421
422 /// Compile every `http_call` parameter.
423 fn compile_http_call_logic(
424 &self,
425 config: &mut HttpCallConfig,
426 task_id: &str,
427 workflow_id: &str,
428 ) -> Result<()> {
429 self.compile_template(
430 &mut config.connector,
431 "http_call connector",
432 task_id,
433 workflow_id,
434 )?;
435 self.compile_template(
436 &mut config.timeout_ms,
437 "http_call timeout_ms",
438 task_id,
439 workflow_id,
440 )?;
441 for (name, value) in &mut config.headers {
442 let what = format!("http_call header {name}");
443 self.compile_template(value, &what, task_id, workflow_id)?;
444 }
445 for (what, field) in [
446 ("http_call path", &mut config.path),
447 ("http_call body", &mut config.body),
448 ("http_call body_format", &mut config.body_format),
449 ("http_call response_path", &mut config.response_path),
450 ("http_call response_format", &mut config.response_format),
451 ] {
452 self.compile_template_field(field, what, task_id, workflow_id)?;
453 }
454 Ok(())
455 }
456
457 /// Compile every `enrich` parameter.
458 fn compile_enrich_logic(
459 &self,
460 config: &mut EnrichConfig,
461 task_id: &str,
462 workflow_id: &str,
463 ) -> Result<()> {
464 self.compile_template(
465 &mut config.connector,
466 "enrich connector",
467 task_id,
468 workflow_id,
469 )?;
470 self.compile_template(
471 &mut config.merge_path,
472 "enrich merge_path",
473 task_id,
474 workflow_id,
475 )?;
476 self.compile_template(
477 &mut config.timeout_ms,
478 "enrich timeout_ms",
479 task_id,
480 workflow_id,
481 )?;
482 self.compile_template_field(&mut config.path, "enrich path", task_id, workflow_id)
483 }
484
485 /// Compile every `publish_kafka` parameter.
486 fn compile_publish_kafka_logic(
487 &self,
488 config: &mut PublishKafkaConfig,
489 task_id: &str,
490 workflow_id: &str,
491 ) -> Result<()> {
492 self.compile_template(
493 &mut config.connector,
494 "publish_kafka connector",
495 task_id,
496 workflow_id,
497 )?;
498 self.compile_template(
499 &mut config.topic,
500 "publish_kafka topic",
501 task_id,
502 workflow_id,
503 )?;
504 self.compile_template_field(&mut config.key, "publish_kafka key", task_id, workflow_id)?;
505 self.compile_template_field(
506 &mut config.value,
507 "publish_kafka value",
508 task_id,
509 workflow_id,
510 )
511 }
512
513 /// Compile a required `Template` parameter against `self.template_compiler`.
514 /// `what` labels the compile-error context as `"{what} for task {task_id}
515 /// in workflow {workflow_id}"`, e.g. `"http_call connector"`.
516 fn compile_template(
517 &self,
518 field: &mut Template,
519 what: &str,
520 task_id: &str,
521 workflow_id: &str,
522 ) -> Result<()> {
523 field.compile(&self.template_compiler, &label(what, task_id, workflow_id))
524 }
525
526 /// Compile an optional built-in integration `Template` field — `path_logic`,
527 /// against `self.template_compiler`.
528 /// A `None` field is a no-op, matching every one of these fields being
529 /// optional. `what` labels the compile-error context as `"{what} for task
530 /// {task_id} in workflow {workflow_id}"`, e.g. `"http_call body"`.
531 fn compile_template_field(
532 &self,
533 field: &mut Option<Template>,
534 what: &str,
535 task_id: &str,
536 workflow_id: &str,
537 ) -> Result<()> {
538 if let Some(t) = field {
539 t.compile(&self.template_compiler, &label(what, task_id, workflow_id))?;
540 }
541 Ok(())
542 }
543}
544
545/// Format a JSONLogic compile-error label as `"{what} for task {task_id} in
546/// workflow {workflow_id}"` — the shape shared by every built-in whose
547/// context needs no further detail (a few, like map mappings and validation
548/// rules, append per-item detail and format their own label instead).
549fn label(what: &str, task_id: &str, workflow_id: &str) -> String {
550 format!("{what} for task {task_id} in workflow {workflow_id}")
551}
552
553#[cfg(test)]
554mod tests {
555 //! Pins the datalogic operator semantics this crate's own behaviour
556 //! depends on. Not an attempt at a general operator-semantics table — that
557 //! was investigated and refused: `datalogic-rs` keeps `mod opcode;` private
558 //! and `OpCode` `pub(crate)`, so this crate could only hand-maintain the
559 //! same unverified table one layer lower, and it would actively mislead —
560 //! see `an_unrecognised_operator_is_not_an_error_under_templating` below,
561 //! which is exactly the case a static "known operators" table would get
562 //! wrong. Every value here was read from a live `datalogic_rs::Engine`
563 //! built the way `LogicCompiler::new` builds one, not assumed.
564 //!
565 //! If a `datalogic-rs` upgrade changes any of these, that is a real
566 //! behaviour change for every workflow in production — these tests exist
567 //! so it fails CI instead of surfacing as a support ticket.
568 //!
569 //! These values are also *feature*-dependent. This crate exposes the
570 //! `datalogic-rs` operator families as cargo features, all off by default.
571 //! Any test whose answer changes when a family is enabled carries a
572 //! `#[cfg(feature = ...)]` so **both** configurations stay pinned —
573 //! otherwise the `--all-features` CI run would be the only one checking
574 //! anything and the default build, which is what `cargo add dataflow-rs`
575 //! delivers, would go untested.
576
577 use super::*;
578 use serde_json::json;
579
580 /// The exact engine construction `LogicCompiler::new` uses: templating
581 /// enabled, plus whichever `datalogic-rs` operator families this crate's
582 /// cargo features turned on — none, by default. Which families are live is
583 /// fixed at compile time, so a test whose result depends on one must be
584 /// `#[cfg]`-gated rather than assuming the default build.
585 fn engine() -> Engine {
586 crate::engine::compiler::datalogic_engine_builder().build()
587 }
588
589 fn eval(engine: &Engine, logic: &Value) -> Value {
590 let compiled = engine.compile_arc(logic).expect("should compile");
591 let ctx = datavalue::OwnedDataValue::from(&json!({}));
592 serde_json::from_str(
593 &engine
594 .session()
595 .eval_str(&compiled, &ctx)
596 .expect("should evaluate"),
597 )
598 .expect("eval_str output should be valid JSON")
599 }
600
601 /// A one-task workflow carrying `extra` as additional top-level JSON keys.
602 fn workflow_json(extra: &str) -> String {
603 format!(
604 r#"{{ "id": "w", "name": "w", {extra}
605 "tasks": [{{"id": "t", "name": "t",
606 "function": {{"name": "map", "input": {{"mappings": []}}}}}}] }}"#
607 )
608 }
609
610 #[test]
611 fn compile_workflows_precomputes_the_loop_counter_path() {
612 let workflow =
613 Workflow::from_json(&workflow_json(r#""loop": {"counter": "i", "max": 3},"#))
614 .expect("should parse");
615
616 let compiled = LogicCompiler::new()
617 .compile_workflows(vec![workflow])
618 .expect("should compile");
619
620 let cfg = compiled[0].loop_config.as_ref().expect("loop config");
621 let parts: Vec<&str> = cfg.counter_parts.iter().map(Arc::as_ref).collect();
622 assert_eq!(parts, ["temp_data", "i"]);
623 }
624
625 #[test]
626 fn compile_workflows_rejects_an_invalid_loop_config() {
627 // `Workflow::validate` runs inside `compile_workflows`, so a bound that
628 // could never advance fails engine construction rather than the first
629 // message.
630 let workflow =
631 Workflow::from_json(&workflow_json(r#""loop": {"init": 5, "max": 5},"#)).unwrap();
632
633 assert!(
634 LogicCompiler::new()
635 .compile_workflows(vec![workflow])
636 .is_err()
637 );
638 }
639
640 #[test]
641 fn compile_workflows_leaves_a_non_looping_workflow_without_a_loop() {
642 let workflow = Workflow::from_json(&workflow_json("")).expect("should parse");
643
644 let compiled = LogicCompiler::new()
645 .compile_workflows(vec![workflow])
646 .expect("should compile");
647
648 assert!(compiled[0].loop_config.is_none());
649 }
650
651 #[test]
652 fn empty_operand_results_this_crate_would_silently_break_on() {
653 // A workflow author can write any of these — a map mapping folding an
654 // empty list, a filter condition over an empty selector — and the
655 // crate never validates operand count. If a datalogic upgrade changed
656 // any of these defaults, every workflow relying on the vacuous case
657 // would silently start producing a different value.
658 let e = engine();
659 for (logic, expected) in [
660 (json!({"and": []}), json!(null)),
661 (json!({"or": []}), json!(null)),
662 (json!({"+": []}), json!(0)),
663 (json!({"*": []}), json!(1)),
664 (json!({"cat": []}), json!("")),
665 (json!({"merge": []}), json!([])),
666 (json!({"missing": []}), json!([])),
667 ] {
668 assert_eq!(eval(&e, &logic), expected, "for {logic}");
669 }
670 }
671
672 #[test]
673 fn a_missing_var_path_resolves_to_null_not_an_error() {
674 // The exact mechanism behind the pitfall CLAUDE.md documents for
675 // `payload.*` expressions: a `var` over a path that does not resolve
676 // is `Null`, silently, never `Err`. `Template::eval` and the built-in
677 // `*_logic` fields inherit this — there is no engine-level signal that
678 // distinguishes "field absent" from "field is null".
679 let e = engine();
680 assert_eq!(
681 eval(&e, &json!({"var": "data.does_not_exist"})),
682 json!(null)
683 );
684 }
685
686 #[test]
687 fn truthy_falsy_matches_the_documented_semantics() {
688 // Verifies the claim in docs/src/advanced/jsonlogic.md's Truthy/Falsy
689 // section, which is a `json` fence and therefore NOT compiled by
690 // dataflow-docs-tests — this is the only check on that claim.
691 // Notable and easy to get wrong: an empty object `{}` is falsy here,
692 // unlike some JSONLogic implementations that treat any object as truthy.
693 let e = engine();
694 for (v, truthy) in [
695 (json!(0), false),
696 (json!(""), false),
697 (json!(false), false),
698 (json!(null), false),
699 (json!([]), false),
700 (json!({}), false),
701 (json!("x"), true),
702 (json!(1), true),
703 ] {
704 assert_eq!(
705 eval(&e, &json!({"!!": v})),
706 json!(truthy),
707 "truthiness of {v}"
708 );
709 }
710 }
711
712 #[test]
713 fn an_unrecognised_operator_is_not_an_error_under_templating() {
714 // The load-bearing fact behind #26's refusal of a static "known
715 // operators" table, and the reason `Template` documents itself as
716 // opt-in per field rather than a blanket JSON wrapper: under
717 // templating (which LogicCompiler and TemplateCompiler both enable),
718 // an outright typo neither fails to compile nor fails to evaluate. It
719 // echoes back as a literal structured object instead — a workflow
720 // author who mistypes an operator name gets silent pass-through, not a
721 // validation error. True under every feature combination, so this half
722 // of the tripwire is unconditional.
723 let e = engine();
724 let logic = json!({"totally_made_up_op_xyz": ["a", "b"]});
725 assert_eq!(
726 eval(&e, &logic),
727 logic,
728 "an unrecognised operator must echo back verbatim, not error"
729 );
730 }
731
732 /// With `ext-string` off, `starts_with` is not a name the engine knows, so
733 /// it is indistinguishable from the typo above: silent pass-through. This
734 /// is the failure mode a workflow author hits when they reach for an
735 /// operator whose family this build did not enable — no error, just a
736 /// wrong value.
737 #[cfg(not(feature = "ext-string"))]
738 #[test]
739 fn a_gated_operator_echoes_back_while_its_family_is_off() {
740 let e = engine();
741 let logic = json!({"starts_with": ["hello", "he"]});
742 assert_eq!(
743 eval(&e, &logic),
744 logic,
745 "an operator behind an unenabled family must echo back, not error"
746 );
747 }
748
749 /// The other side of the same coin, and the reason enabling a family is
750 /// not a no-op for existing workflows: `ext-string` converts a previously
751 /// inert `{"starts_with": [...]}` *literal* into a live operator call.
752 /// Anyone carrying such an object as data through a `map` mapping sees
753 /// their value silently replaced by the operator's result.
754 #[cfg(feature = "ext-string")]
755 #[test]
756 fn a_gated_operator_evaluates_once_its_family_is_on() {
757 let e = engine();
758 assert_eq!(
759 eval(&e, &json!({"starts_with": ["hello", "he"]})),
760 json!(true),
761 "with ext-string on, starts_with must evaluate, not echo"
762 );
763 }
764
765 /// `datetime` is the one family that is not confined to new operator
766 /// names. `datalogic-rs`'s comparison path probes *plain strings* for a
767 /// datetime/duration shape before falling back to byte comparison, so
768 /// `==` and the ordering operators change answers on date-shaped
769 /// operands. These two strings are different byte sequences naming the
770 /// same instant.
771 #[test]
772 fn datetime_feature_changes_plain_string_comparison() {
773 let e = engine();
774 let logic = json!({"==": ["2024-01-15T00:00:00Z", "2024-01-15T01:00:00+01:00"]});
775 #[cfg(feature = "datetime")]
776 assert_eq!(eval(&e, &logic), json!(true));
777 #[cfg(not(feature = "datetime"))]
778 assert_eq!(eval(&e, &logic), json!(false));
779 }
780
781 /// Each family's cargo feature actually reaches `datalogic-rs`. One
782 /// representative operator per family is enough — the feature either
783 /// forwards or it does not.
784 #[cfg(feature = "ext-string")]
785 #[test]
786 fn ext_string_feature_reaches_datalogic() {
787 let e = engine();
788 assert_eq!(eval(&e, &json!({"upper": "ab"})), json!("AB"));
789 }
790
791 #[cfg(feature = "ext-array")]
792 #[test]
793 fn ext_array_feature_reaches_datalogic() {
794 let e = engine();
795 assert_eq!(eval(&e, &json!({"sort": [[3, 1, 2]]})), json!([1, 2, 3]));
796 }
797
798 #[cfg(feature = "ext-math")]
799 #[test]
800 fn ext_math_feature_reaches_datalogic() {
801 let e = engine();
802 assert_eq!(eval(&e, &json!({"abs": -5})), json!(5));
803 }
804
805 #[cfg(feature = "ext-control")]
806 #[test]
807 fn ext_control_feature_reaches_datalogic() {
808 let e = engine();
809 assert_eq!(
810 eval(&e, &json!({"??": [null, "fallback"]})),
811 json!("fallback")
812 );
813 }
814
815 #[cfg(feature = "ext-object")]
816 #[test]
817 fn ext_object_feature_reaches_datalogic() {
818 let e = engine();
819 assert_eq!(
820 eval(&e, &json!({"keys": [{"a": 1, "b": 2}]})),
821 json!(["a", "b"])
822 );
823 }
824
825 #[cfg(feature = "error-handling")]
826 #[test]
827 fn error_handling_feature_reaches_datalogic() {
828 // `error-handling` is the JSONLogic `try`/`throw` pair — unrelated to
829 // this crate's own always-on error handling.
830 let e = engine();
831 assert_eq!(
832 eval(&e, &json!({"try": [{"throw": "boom"}, "recovered"]})),
833 json!("recovered")
834 );
835 }
836
837 /// The `datetime` family's own operators. Their exact output depends on
838 /// the ambient clock and on format details this crate does not pin, so
839 /// assert only the property the feature actually buys: the operator is
840 /// recognised and evaluates, rather than echoing back as a literal.
841 #[cfg(feature = "datetime")]
842 #[test]
843 fn datetime_feature_reaches_datalogic() {
844 let e = engine();
845 let logic = json!({"now": []});
846 let result = eval(&e, &logic);
847 assert_ne!(result, logic, "with datetime on, `now` must not echo back");
848 assert!(!result.is_null(), "`now` should produce a value, got null");
849 }
850}