dataflow_rs/engine/functions/template.rs
1//! # Template
2//!
3//! A config field whose authored JSON is a JSONLogic expression. Every
4//! parameter of every built-in function is one of these, and custom handlers
5//! declare them for their own config.
6//!
7//! A literal is JSONLogic for itself, so the static spelling an author already
8//! writes — `"data.output"`, `30000`, `{"X-Env": "prod"}` — is a valid
9//! `Template`. Those fold to a constant at compile time and are cached, so a
10//! statically-authored parameter does no per-message work. See
11//! [`Template::is_constant`].
12
13use crate::engine::error::{DataflowError, Result};
14use crate::engine::task_context::{ContextView, TaskContext};
15use datalogic_rs::Logic;
16use datavalue::OwnedDataValue;
17use serde::{Deserialize, Deserializer};
18use serde_json::Value;
19use std::borrow::Cow;
20use std::sync::Arc;
21
22/// Coerce an already-evaluated value to a *plain* string: a string yields its
23/// contents, anything else its compact JSON form.
24///
25/// Mirrors [`crate::engine::executor::eval_to_plain_string`] exactly, for the
26/// constant-cache path that never reaches the evaluator.
27/// `constant_and_evaluated_plain_strings_agree` pins the two together.
28pub(crate) fn plain_string_of(value: &OwnedDataValue) -> String {
29 match value {
30 OwnedDataValue::String(s) => s.clone(),
31 other => other.to_string(),
32 }
33}
34
35/// What a [`Template`] evaluates against: a [`TaskContext`], which converts
36/// the message context into the arena per evaluation, or a [`ContextView`],
37/// which converted it once. Private, so the public surface is the named
38/// method pairs (`resolve` / `resolve_in`, …) and the two cannot drift.
39trait Source {
40 fn eval(&self, logic: &Logic) -> Result<OwnedDataValue>;
41 fn eval_json(&self, logic: &Logic) -> Result<Value>;
42 fn eval_to_plain_string(&self, logic: &Logic) -> Result<String>;
43}
44
45impl Source for TaskContext<'_> {
46 fn eval(&self, logic: &Logic) -> Result<OwnedDataValue> {
47 TaskContext::eval(self, logic)
48 }
49 fn eval_json(&self, logic: &Logic) -> Result<Value> {
50 TaskContext::eval_json(self, logic)
51 }
52 fn eval_to_plain_string(&self, logic: &Logic) -> Result<String> {
53 TaskContext::eval_to_plain_string(self, logic)
54 }
55}
56
57impl Source for ContextView<'_> {
58 fn eval(&self, logic: &Logic) -> Result<OwnedDataValue> {
59 ContextView::eval(self, logic)
60 }
61 fn eval_json(&self, logic: &Logic) -> Result<Value> {
62 ContextView::eval_json(self, logic)
63 }
64 fn eval_to_plain_string(&self, logic: &Logic) -> Result<String> {
65 ContextView::eval_to_plain_string(self, logic)
66 }
67}
68
69/// A config field whose authored JSON is a JSONLogic expression.
70///
71/// Deserializes from any JSON value and keeps it verbatim; the expression is
72/// compiled once at engine construction (see [`crate::AsyncFunctionHandler::compile_input`])
73/// and evaluated per message on the worker thread's pooled arena — unless it
74/// folded to a constant, in which case the value is computed once at
75/// construction and handed back directly.
76///
77/// # Literals and the `$` escape
78///
79/// A JSON scalar or array authored here is a literal: `"data.out"` resolves to
80/// the string `data.out`, `30000` to the number. An *object* is where care is
81/// needed, because the engine evaluates in templating mode: a single-key object
82/// whose key matches an operator name is that operator. `{"cat": ["a", "b"]}`
83/// resolves to `"ab"`, not to the object.
84///
85/// Prefix the key with [`Engine::template_key_escape`](crate::Engine::template_key_escape)
86/// (`$`) to force the literal reading: `{"$cat": ["a", "b"]}` resolves to the
87/// object `{"cat": ["a", "b"]}`. One prefix is stripped from every template key,
88/// so a genuinely `$`-prefixed key doubles up — `{"$$oid": …}` emits `$oid`.
89///
90/// Before that escape existed a literal object with a colliding key was
91/// inexpressible, which is why this type used to be documented as opt-in per
92/// field. It no longer is: any config field may be a `Template`.
93#[derive(Debug, Clone)]
94pub struct Template {
95 raw: Value,
96 /// Everything [`Self::compile`] produces, behind one pointer.
97 ///
98 /// Boxed to keep `Template` small. Every parameter of every built-in is one
99 /// of these — `HttpCallConfig` alone holds eight — and they live inside
100 /// `FunctionConfig`, whose size is the size of its largest variant. Inline,
101 /// the compiled state made that enum large enough for
102 /// `clippy::large_enum_variant`. The indirection costs one deref on a path
103 /// that is either cached or about to run a JSONLogic evaluation anyway.
104 compiled: Option<Box<Compiled>>,
105}
106
107/// What compiling a [`Template`] produces.
108#[derive(Debug, Clone)]
109struct Compiled {
110 logic: Arc<Logic>,
111 /// `Some` when the expression folded to a compile-time constant — the value
112 /// every `resolve_*` returns without touching the evaluator.
113 constant: Option<OwnedDataValue>,
114}
115
116// Hand-written rather than `#[serde(from = "Value")]` plus `impl From<Value>`:
117// a container-level `from` builds the target solely through `From`, so a
118// field-level `#[serde(skip)]` on `compiled` would be inert and misleading next
119// to a manual `From` impl anyway. This is the same five lines, explicit about
120// which path runs.
121impl<'de> Deserialize<'de> for Template {
122 fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
123 Ok(Self {
124 raw: Value::deserialize(d)?,
125 compiled: None,
126 })
127 }
128}
129
130impl Default for Template {
131 /// A `Template` over JSON `null`.
132 ///
133 /// Exists so config structs that derive `Default` still can. It is a
134 /// placeholder, not a usable parameter — every config carrying one names
135 /// the field as required, so a `Default`-built config is a base to fill in.
136 fn default() -> Self {
137 Self::from(Value::Null)
138 }
139}
140
141impl From<Value> for Template {
142 /// An uncompiled `Template` over `raw`. For hosts and tests building a
143 /// config struct directly rather than deserializing one; `LogicCompiler`
144 /// still has to compile it before any `resolve_*` call will succeed.
145 fn from(raw: Value) -> Self {
146 Self {
147 raw,
148 compiled: None,
149 }
150 }
151}
152
153impl Template {
154 /// Compile the expression. Called once at engine construction via
155 /// [`crate::AsyncFunctionHandler::compile_input`] or its receiver-taking
156 /// twin [`crate::AsyncFunctionHandler::compile_input_with`]. `label` is used only in
157 /// the error message, matching `LogicCompiler`'s
158 /// `"<what> for task <id> in workflow <id>"` convention.
159 ///
160 /// # Errors
161 ///
162 /// [`DataflowError::LogicEvaluation`] if the expression fails to compile,
163 /// with `label` prefixed onto the message.
164 pub fn compile(&mut self, c: &TemplateCompiler, label: &str) -> Result<()> {
165 let compiled = c
166 .engine
167 .compile_arc(&self.raw)
168 .map_err(|e| DataflowError::LogicEvaluation(format!("{label}: {e}")))?;
169
170 // The datalogic compiler folds every static sub-expression it can
171 // prove, so an expression with no data dependency — which is what a
172 // statically-authored parameter is — collapses to a single literal
173 // node. Evaluate it once here and keep the result: that is what makes
174 // "every parameter is JSONLogic" cost nothing for the static spelling.
175 //
176 // `is_constant`, not `is_static`. `is_static` also reports true for a
177 // rule the compiler *tried* to fold and could not because folding
178 // errored (`{"/": [1, 0]}` divides by zero); evaluating those here
179 // would move a runtime error to build time. A constant rule, by
180 // contrast, has already been reduced to a value and cannot fail.
181 let constant = if compiled.is_constant() {
182 let empty = OwnedDataValue::Object(Vec::new());
183 Some(
184 crate::engine::executor::eval_to_owned(&c.engine, &compiled, &empty)
185 .map_err(|e| DataflowError::LogicEvaluation(format!("{label}: {e}")))?,
186 )
187 } else {
188 None
189 };
190
191 self.compiled = Some(Box::new(Compiled {
192 logic: compiled,
193 constant,
194 }));
195 Ok(())
196 }
197
198 /// Whether the expression folded to a compile-time constant, so every
199 /// `resolve_*` call returns a cached value instead of evaluating.
200 ///
201 /// True for the static spelling of any parameter — a scalar, an array, or
202 /// an object template with no `var` in it. False once anything reads the
203 /// message. Callers that precompute a derived form (a split write path, for
204 /// instance) branch on this.
205 ///
206 /// Meaningless before [`Self::compile`]: an uncompiled `Template` reports
207 /// `false` because nothing has been folded yet, not because the expression
208 /// is dynamic.
209 pub fn is_constant(&self) -> bool {
210 self.constant().is_some()
211 }
212
213 /// The folded constant, when the expression compiled to one.
214 fn constant(&self) -> Option<&OwnedDataValue> {
215 self.compiled.as_ref().and_then(|c| c.constant.as_ref())
216 }
217
218 /// The folded constant coerced to a plain string, when the expression
219 /// folded to one.
220 ///
221 /// Lets a caller do at compile time what [`Self::resolve_string`] would
222 /// otherwise defer to the first message — which is how [`PathTemplate`]
223 /// precomputes a static write path.
224 ///
225 /// [`PathTemplate`]: crate::PathTemplate
226 pub fn constant_string(&self) -> Option<String> {
227 self.constant().map(plain_string_of)
228 }
229
230 /// The parameter's value for this message: the cached constant when the
231 /// expression folded, otherwise a fresh evaluation.
232 ///
233 /// This is the sanctioned read for a config parameter. [`Self::eval`] is
234 /// the same thing without the constant cache, kept for handlers that hold
235 /// a `Template` they compiled themselves.
236 ///
237 /// A field that reads the message converts the whole context into the
238 /// arena per call. A handler resolving several should use
239 /// [`Self::resolve_in`] inside [`TaskContext::with_view`] instead.
240 ///
241 /// # Errors
242 ///
243 /// As [`Self::eval`].
244 pub fn resolve(&self, ctx: &TaskContext<'_>) -> Result<OwnedDataValue> {
245 self.resolve_from(ctx)
246 }
247
248 /// As [`Self::resolve`], against a view built once by
249 /// [`TaskContext::with_view`].
250 ///
251 /// # Errors
252 ///
253 /// As [`Self::eval`].
254 pub fn resolve_in(&self, view: &ContextView<'_>) -> Result<OwnedDataValue> {
255 self.resolve_from(view)
256 }
257
258 fn resolve_from(&self, src: &impl Source) -> Result<OwnedDataValue> {
259 if let Some(v) = self.constant() {
260 return Ok(v.clone());
261 }
262 if let Some(v) = self.uncompiled_literal() {
263 return Ok(v);
264 }
265 src.eval(self.compiled_or_err("eval")?)
266 }
267
268 /// The authored value, for a config that never went through
269 /// `LogicCompiler` — a struct built by hand in a test, a benchmark, or a
270 /// host helper.
271 ///
272 /// Only JSON scalars qualify. A scalar is unambiguously itself in
273 /// JSONLogic, so reading it directly cannot disagree with what compilation
274 /// would have produced. An object may be an operator call and an array's
275 /// elements may each be one, so those still need the compiler and fall
276 /// through to the "never compiled" error.
277 ///
278 /// This is what keeps the pre-3.9 contract for directly-constructed
279 /// configs: before, these parameters were plain `String`/`u64` fields that
280 /// needed no compilation at all.
281 fn uncompiled_literal(&self) -> Option<OwnedDataValue> {
282 if self.compiled.is_some() {
283 return None;
284 }
285 match &self.raw {
286 Value::String(_) | Value::Number(_) | Value::Bool(_) => {
287 Some(OwnedDataValue::from(&self.raw))
288 }
289 _ => None,
290 }
291 }
292
293 /// As [`Self::resolve`], coerced to a *plain* string — a string result
294 /// yields its contents, anything else its compact JSON form. Use this
295 /// wherever the value becomes a URL path, a header value, a topic name or a
296 /// write path, where JSON quoting would be wrong.
297 ///
298 /// # Errors
299 ///
300 /// As [`Self::eval`].
301 pub fn resolve_string(&self, ctx: &TaskContext<'_>) -> Result<String> {
302 self.resolve_string_from(ctx)
303 }
304
305 /// As [`Self::resolve_string`], against a view built once by
306 /// [`TaskContext::with_view`].
307 ///
308 /// # Errors
309 ///
310 /// As [`Self::eval`].
311 pub fn resolve_string_in(&self, view: &ContextView<'_>) -> Result<String> {
312 self.resolve_string_from(view)
313 }
314
315 fn resolve_string_from(&self, src: &impl Source) -> Result<String> {
316 if let Some(v) = self.constant() {
317 return Ok(plain_string_of(v));
318 }
319 if let Some(v) = self.uncompiled_literal() {
320 return Ok(plain_string_of(&v));
321 }
322 src.eval_to_plain_string(self.compiled_or_err("eval_to_plain_string")?)
323 }
324
325 /// As [`Self::resolve_string`], against a context already resident in
326 /// `arena`, for callers inside an arena scope that hold no [`TaskContext`].
327 ///
328 /// The built-in sync executors (`map`, `parse`, `publish`) run against an
329 /// [`ArenaContext`](crate::engine::executor::ArenaContext) that earlier
330 /// tasks in the same stretch already populated. Routing them through
331 /// `TaskContext` would re-walk the whole owned context into the arena per
332 /// parameter, which is exactly the cost that context exists to avoid.
333 ///
334 /// # Errors
335 ///
336 /// As [`Self::resolve_string`].
337 pub(crate) fn resolve_string_in_arena(
338 &self,
339 p: crate::engine::functions::path_template::ParamCtx<'_>,
340 ) -> Result<String> {
341 Ok(self.resolve_str_in_arena(p)?.into_owned())
342 }
343
344 /// As [`Self::resolve_string_in_arena`], borrowing when it can.
345 ///
346 /// A constant string parameter — the static spelling of a source, a target,
347 /// a topic — is already a `String` on the compiled template, so returning
348 /// it by value allocates on every message. These resolve per task per
349 /// message on the sync path, where that allocation is precisely the cost
350 /// the constant cache exists to avoid.
351 ///
352 /// # Errors
353 ///
354 /// As [`Self::resolve_string`].
355 pub(crate) fn resolve_str_in_arena(
356 &self,
357 p: crate::engine::functions::path_template::ParamCtx<'_>,
358 ) -> Result<Cow<'_, str>> {
359 match self.constant() {
360 Some(OwnedDataValue::String(s)) => return Ok(Cow::Borrowed(s)),
361 Some(other) => return Ok(Cow::Owned(plain_string_of(other))),
362 None => {}
363 }
364 // Uncompiled literal string: borrow straight off the authored JSON.
365 if self.compiled.is_none() {
366 if let Value::String(s) = &self.raw {
367 return Ok(Cow::Borrowed(s));
368 }
369 if let Some(v) = self.uncompiled_literal() {
370 return Ok(Cow::Owned(plain_string_of(&v)));
371 }
372 }
373 let logic = self.compiled_or_err("resolve_str_in_arena")?;
374 let evaluated = p
375 .engine()
376 .evaluate(logic, *p.context(), p.arena())
377 .map_err(|e| crate::engine::error::from_datalogic_eval(&e))?;
378 Ok(Cow::Owned(match evaluated {
379 datavalue::DataValue::String(s) => s.to_string(),
380 other => other.to_string(),
381 }))
382 }
383
384 /// The compiled logic, or the "never compiled" error naming `method`.
385 fn compiled_or_err(&self, method: &str) -> Result<&Logic> {
386 self.compiled.as_ref().map(|c| &*c.logic).ok_or_else(|| {
387 DataflowError::LogicEvaluation(format!(
388 "Template::{method} called before Template::compile — the engine did not \
389 compile this field at construction time"
390 ))
391 })
392 }
393
394 /// As [`Self::resolve`], as a `u64` — for parameters like `timeout_ms`.
395 ///
396 /// # Errors
397 ///
398 /// As [`Self::eval`], plus [`DataflowError::Validation`] when the result is
399 /// not a number that fits a `u64`. A timeout that evaluated to `null`
400 /// because its path was missing is a configuration error worth reporting,
401 /// not something to silently default.
402 pub fn resolve_u64(&self, ctx: &TaskContext<'_>, label: &str) -> Result<u64> {
403 self.resolve_u64_from(ctx, label)
404 }
405
406 /// As [`Self::resolve_u64`], against a view built once by
407 /// [`TaskContext::with_view`].
408 ///
409 /// # Errors
410 ///
411 /// As [`Self::resolve_u64`].
412 pub fn resolve_u64_in(&self, view: &ContextView<'_>, label: &str) -> Result<u64> {
413 self.resolve_u64_from(view, label)
414 }
415
416 fn resolve_u64_from(&self, src: &impl Source, label: &str) -> Result<u64> {
417 let value = self.resolve_from(src)?;
418 match &value {
419 OwnedDataValue::Number(n) => {
420 // Reject NaN, negatives and anything past u64 range before the
421 // `as` cast, which would otherwise saturate or produce 0.
422 let f = n.as_f64();
423 (f.is_finite() && f >= 0.0 && f <= u64::MAX as f64).then_some(f as u64)
424 }
425 _ => None,
426 }
427 .ok_or_else(|| {
428 DataflowError::Validation(format!(
429 "{label} must evaluate to a non-negative number, got {value}"
430 ))
431 })
432 }
433
434 /// Evaluate against the message context, on the worker thread's pooled bump
435 /// arena.
436 ///
437 /// # Errors
438 ///
439 /// [`DataflowError::LogicEvaluation`] if [`Self::compile`] was never called —
440 /// naming the field is the caller's job via `label`, since this type has no
441 /// field name of its own to report — or if evaluation itself fails.
442 pub fn eval(&self, ctx: &TaskContext<'_>) -> Result<OwnedDataValue> {
443 ctx.eval(self.compiled_or_err("eval")?)
444 }
445
446 /// As [`Self::eval`], against a view built once by
447 /// [`TaskContext::with_view`].
448 ///
449 /// # Errors
450 ///
451 /// As [`Self::eval`].
452 pub fn eval_in(&self, view: &ContextView<'_>) -> Result<OwnedDataValue> {
453 view.eval(self.compiled_or_err("eval")?)
454 }
455
456 /// As [`Self::eval`], deserialized into `T`.
457 ///
458 /// Routes through `serde_json::Value` — [`TaskContext::eval_json`] then
459 /// `serde_json::from_value` — so it costs one extra walk and rebuild past
460 /// [`Self::eval`]. Prefer `eval` when `T` is `OwnedDataValue` or when you
461 /// only need to inspect the result, not deserialize it into a caller type.
462 ///
463 /// # Errors
464 ///
465 /// As [`Self::eval`], plus a deserialization error if the evaluated JSON does
466 /// not fit `T`.
467 pub fn eval_into<T: serde::de::DeserializeOwned>(&self, ctx: &TaskContext<'_>) -> Result<T> {
468 self.eval_into_from(ctx)
469 }
470
471 /// As [`Self::eval_into`], against a view built once by
472 /// [`TaskContext::with_view`].
473 ///
474 /// # Errors
475 ///
476 /// As [`Self::eval_into`].
477 pub fn eval_into_in<T: serde::de::DeserializeOwned>(
478 &self,
479 view: &ContextView<'_>,
480 ) -> Result<T> {
481 self.eval_into_from(view)
482 }
483
484 fn eval_into_from<T: serde::de::DeserializeOwned>(&self, src: &impl Source) -> Result<T> {
485 let json = src.eval_json(self.compiled_or_err("eval_into")?)?;
486 serde_json::from_value(json).map_err(DataflowError::from_serde)
487 }
488
489 /// As [`Self::eval`], coerced to a *plain* string via
490 /// [`TaskContext::eval_to_plain_string`] — a JSON string result yields its
491 /// contents, anything else its compact JSON form. Use this when the result
492 /// is going into a URL path or a message key, where JSON quoting would be
493 /// wrong.
494 ///
495 /// # Errors
496 ///
497 /// As [`Self::eval`].
498 pub fn eval_to_plain_string(&self, ctx: &TaskContext<'_>) -> Result<String> {
499 ctx.eval_to_plain_string(self.compiled_or_err("eval_to_plain_string")?)
500 }
501
502 /// The authored JSON, unchanged. For handlers that need to report or
503 /// re-serialize their own config.
504 pub fn as_json(&self) -> &Value {
505 &self.raw
506 }
507
508 /// Whether [`Self::compile`] has run. Mainly for tests and for callers that
509 /// want to assert the build pass reached them.
510 pub fn is_compiled(&self) -> bool {
511 self.compiled.is_some()
512 }
513}
514
515/// Handed to [`crate::AsyncFunctionHandler::compile_input`] /
516/// [`crate::AsyncFunctionHandler::compile_input_with`] to compile a handler's
517/// `Template` fields at engine construction.
518///
519/// Wraps the same `Arc<datalogic_rs::Engine>` `LogicCompiler` uses internally,
520/// so a compiled `Template` is evaluable by the engine that will run the
521/// message. A newtype rather than a bare `Arc<datalogic_rs::Engine>` so fields
522/// can be added later without changing `compile_input`'s signature.
523pub struct TemplateCompiler {
524 engine: Arc<datalogic_rs::Engine>,
525}
526
527impl TemplateCompiler {
528 pub(crate) fn new(engine: Arc<datalogic_rs::Engine>) -> Self {
529 Self { engine }
530 }
531
532 /// The shared datalogic engine, for handlers that need to compile something
533 /// other than a `Template` field directly.
534 pub fn engine(&self) -> &datalogic_rs::Engine {
535 &self.engine
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use crate::engine::message::Message;
543 use serde_json::json;
544
545 fn engine() -> Arc<datalogic_rs::Engine> {
546 Arc::new(crate::engine::compiler::datalogic_engine_builder().build())
547 }
548
549 fn template_from(v: Value) -> Template {
550 serde_json::from_value(v).unwrap()
551 }
552
553 #[test]
554 fn deserializes_from_every_json_shape_and_as_json_is_verbatim() {
555 for v in [
556 json!({"a": 1}),
557 json!([1, 2, 3]),
558 json!("hello"),
559 json!(42),
560 json!(true),
561 json!(null),
562 json!({}),
563 ] {
564 let t = template_from(v.clone());
565 assert_eq!(t.as_json(), &v);
566 assert!(!t.is_compiled());
567 }
568 }
569
570 #[test]
571 fn view_and_per_call_resolution_agree_on_values_and_errors() {
572 let dl = engine();
573 let c = TemplateCompiler::new(dl.clone());
574 let mut m = Message::from_value(&json!({}));
575 crate::engine::utils::set_nested_value(
576 &mut m.context,
577 "data",
578 OwnedDataValue::from(&json!({"n": "nan", "k": 5, "s": "x"})),
579 );
580 let ctx = TaskContext::new(&mut m, &dl);
581 for raw in [
582 json!({"var": "data.n"}),
583 json!({"var": "data.k"}),
584 json!({"var": "data.s"}),
585 json!({"/": [{"var": "data.k"}, 0]}),
586 json!({"+": [1, 2]}),
587 ] {
588 let mut t = template_from(raw.clone());
589 t.compile(&c, "t").unwrap();
590 let show = |r: Result<OwnedDataValue>| format!("{r:?}");
591 assert_eq!(
592 show(t.resolve(&ctx)),
593 ctx.with_view(|v| show(t.resolve_in(v))),
594 "{raw}"
595 );
596 assert_eq!(
597 format!("{:?}", t.resolve_string(&ctx)),
598 ctx.with_view(|v| format!("{:?}", t.resolve_string_in(v))),
599 "{raw}"
600 );
601 assert_eq!(
602 format!("{:?}", t.resolve_u64(&ctx, "t")),
603 ctx.with_view(|v| format!("{:?}", t.resolve_u64_in(v, "t"))),
604 "{raw}"
605 );
606 assert_eq!(
607 show(t.eval(&ctx)),
608 ctx.with_view(|v| show(t.eval_in(v))),
609 "{raw}"
610 );
611 }
612 }
613
614 #[test]
615 fn eval_before_compile_errors_without_panicking() {
616 let dl = engine();
617 let mut m = Message::from_value(&json!({}));
618 let ctx = TaskContext::new(&mut m, &dl);
619 let t = template_from(json!({"var": "data.x"}));
620
621 match t.eval(&ctx) {
622 Err(DataflowError::LogicEvaluation(msg)) => {
623 assert!(
624 msg.contains("compile"),
625 "message should name the cause: {msg}"
626 );
627 }
628 other => panic!("expected LogicEvaluation, got {other:?}"),
629 }
630 }
631
632 #[test]
633 fn compile_on_a_malformed_expression_names_the_label() {
634 // datalogic-rs's templating mode is deliberately permissive at compile
635 // time: an unrecognised operator key compiles as a literal (or, at the
636 // top level, a structured-object template) rather than erroring — this
637 // is existing engine behaviour, not something `Template` controls, and
638 // it is why a static "known operators" table would mislead (see #26's
639 // scope notes). The one thing that reliably fails to *compile* — as
640 // opposed to failing at *evaluation* — is rule nesting past the
641 // engine's `MAX_COMPILE_DEPTH` (256), verified directly against
642 // datalogic-rs 5.1.1 before writing this test.
643 let c = TemplateCompiler::new(engine());
644 let mut too_deep = json!(1);
645 for _ in 0..300 {
646 too_deep = json!({"var": too_deep});
647 }
648 let mut t = template_from(too_deep);
649
650 match t.compile(&c, "my_field for task t in workflow w") {
651 Err(DataflowError::LogicEvaluation(msg)) => {
652 assert!(
653 msg.contains("my_field for task t in workflow w"),
654 "got: {msg}"
655 );
656 }
657 other => panic!("expected LogicEvaluation, got {other:?}"),
658 }
659 }
660
661 #[test]
662 fn a_literal_template_evaluates_to_that_literal() {
663 let dl = engine();
664 let c = TemplateCompiler::new(Arc::clone(&dl));
665 let mut m = Message::from_value(&json!({}));
666 let ctx = TaskContext::new(&mut m, &dl);
667
668 for v in [
669 json!("hello"),
670 json!(42),
671 json!({}),
672 json!({"a": 1, "b": 2}),
673 ] {
674 let mut t = template_from(v.clone());
675 t.compile(&c, "lbl").unwrap();
676 assert_eq!(t.eval_into::<Value>(&ctx).unwrap(), v);
677 }
678 }
679
680 #[test]
681 fn an_operator_named_key_evaluates_unless_it_is_escaped() {
682 // The reason `Template` used to be opt-in per field, and the reason it
683 // no longer needs to be. Templating makes a single-key object an
684 // operator invocation; the `$` escape is what makes the literal
685 // reading expressible at all.
686 let dl = engine();
687 let c = TemplateCompiler::new(Arc::clone(&dl));
688 let mut m = Message::from_value(&json!({}));
689 let ctx = TaskContext::new(&mut m, &dl);
690
691 let mut op = template_from(json!({"cat": ["a", "b"]}));
692 op.compile(&c, "lbl").unwrap();
693 assert_eq!(op.eval_into::<Value>(&ctx).unwrap(), json!("ab"));
694
695 let mut escaped = template_from(json!({"$cat": ["a", "b"]}));
696 escaped.compile(&c, "lbl").unwrap();
697 assert_eq!(
698 escaped.eval_into::<Value>(&ctx).unwrap(),
699 json!({"cat": ["a", "b"]}),
700 "an escaped key must emit the literal object"
701 );
702
703 // One prefix is stripped, so a genuinely `$`-prefixed key doubles up.
704 let mut doubled = template_from(json!({"$$oid": "abc"}));
705 doubled.compile(&c, "lbl").unwrap();
706 assert_eq!(
707 doubled.eval_into::<Value>(&ctx).unwrap(),
708 json!({"$oid": "abc"})
709 );
710 }
711
712 #[test]
713 fn the_static_spelling_of_every_parameter_folds_to_a_constant() {
714 // This is what makes "every parameter is JSONLogic" free: the way an
715 // author already writes a parameter costs no per-message evaluation.
716 let c = TemplateCompiler::new(engine());
717 for v in [
718 json!("data.output"),
719 json!(30000),
720 json!(true),
721 json!(["a", "b"]),
722 json!({"cat": ["a", "b"]}), // folds: no data dependency
723 ] {
724 let mut t = template_from(v.clone());
725 t.compile(&c, "lbl").unwrap();
726 assert!(t.is_constant(), "{v} should fold to a constant");
727 }
728
729 // Anything that reads the message cannot fold.
730 for v in [
731 json!({"var": "data.x"}),
732 json!({"cat": [{"var": "data.x"}]}),
733 ] {
734 let mut t = template_from(v.clone());
735 t.compile(&c, "lbl").unwrap();
736 assert!(!t.is_constant(), "{v} must not fold");
737 }
738 }
739
740 #[test]
741 fn constant_and_evaluated_plain_strings_agree() {
742 // `resolve_string` short-circuits the evaluator for a constant, so its
743 // coercion is a second implementation of `eval_to_plain_string`. If the
744 // two ever disagree, a static parameter and its dynamic twin would put
745 // different bytes in a URL.
746 let dl = engine();
747 let c = TemplateCompiler::new(Arc::clone(&dl));
748 let mut m = Message::from_value(&json!({}));
749 let ctx = TaskContext::new(&mut m, &dl);
750
751 for v in [
752 json!("abc"),
753 json!(7),
754 json!(true),
755 json!(null),
756 json!(["a", 1]),
757 ] {
758 let mut t = template_from(v.clone());
759 t.compile(&c, "lbl").unwrap();
760 assert!(t.is_constant(), "{v} should fold");
761 assert_eq!(
762 t.resolve_string(&ctx).unwrap(),
763 t.eval_to_plain_string(&ctx).unwrap(),
764 "cached and evaluated coercion disagree for {v}"
765 );
766 }
767 }
768
769 #[test]
770 fn an_escaped_key_does_not_fold_to_a_constant() {
771 // Worth pinning because it is counter-intuitive and costs something:
772 // `{"$cat": …}` has no data dependency, yet the compiler keeps it as a
773 // node rather than folding it, so an escaped literal is re-materialised
774 // per message where an unescaped one is cached.
775 //
776 // Only `resolve` (and its typed siblings) are affected — the *value* is
777 // identical either way, which is what the assertion below fixes. If a
778 // future datalogic release starts folding escaped keys this test fails
779 // and the only change needed is to delete it.
780 let dl = engine();
781 let c = TemplateCompiler::new(Arc::clone(&dl));
782 let mut m = Message::from_value(&json!({}));
783 let ctx = TaskContext::new(&mut m, &dl);
784
785 let mut t = template_from(json!({"$a": 1}));
786 t.compile(&c, "lbl").unwrap();
787 assert!(!t.is_constant(), "escaped keys are not folded today");
788 assert_eq!(t.eval_into::<Value>(&ctx).unwrap(), json!({"a": 1}));
789 assert_eq!(
790 t.resolve_string(&ctx).unwrap(),
791 t.eval_to_plain_string(&ctx).unwrap()
792 );
793 }
794
795 #[test]
796 fn resolve_u64_accepts_numbers_and_rejects_everything_else() {
797 let dl = engine();
798 let c = TemplateCompiler::new(Arc::clone(&dl));
799 let mut m = Message::from_value(&json!({}));
800 let ctx = TaskContext::new(&mut m, &dl);
801
802 let mut ok = template_from(json!(30000));
803 ok.compile(&c, "lbl").unwrap();
804 assert_eq!(ok.resolve_u64(&ctx, "timeout_ms").unwrap(), 30000);
805
806 // A missing path resolves to null rather than erroring, so without this
807 // check a mistyped timeout would silently become 0.
808 for bad in [json!(null), json!("30000"), json!(-1), json!({"a": 1})] {
809 let mut t = template_from(bad.clone());
810 t.compile(&c, "lbl").unwrap();
811 let err = t
812 .resolve_u64(&ctx, "timeout_ms")
813 .expect_err("{bad} must be rejected");
814 assert!(err.to_string().contains("timeout_ms"), "{err}");
815 }
816 }
817
818 #[test]
819 fn eval_to_plain_string_unquotes_and_coerces_non_strings() {
820 let dl = engine();
821 let c = TemplateCompiler::new(Arc::clone(&dl));
822 let mut m = Message::from_value(&json!({}));
823 let ctx = TaskContext::new(&mut m, &dl);
824
825 let mut string_t = template_from(json!("abc"));
826 string_t.compile(&c, "lbl").unwrap();
827 assert_eq!(string_t.eval_to_plain_string(&ctx).unwrap(), "abc");
828
829 let mut num_t = template_from(json!(7));
830 num_t.compile(&c, "lbl").unwrap();
831 assert_eq!(num_t.eval_to_plain_string(&ctx).unwrap(), "7");
832
833 let mut obj_t = template_from(json!({"a": 1}));
834 obj_t.compile(&c, "lbl").unwrap();
835 assert_eq!(obj_t.eval_to_plain_string(&ctx).unwrap(), "{\"a\":1}");
836 }
837
838 #[test]
839 fn eval_to_plain_string_before_compile_errors_without_panicking() {
840 let mut m = Message::from_value(&json!({}));
841 let dl = engine();
842 let ctx = TaskContext::new(&mut m, &dl);
843 let t = template_from(json!("abc"));
844
845 match t.eval_to_plain_string(&ctx) {
846 Err(DataflowError::LogicEvaluation(msg)) => {
847 assert!(
848 msg.contains("compile"),
849 "message should name the cause: {msg}"
850 );
851 }
852 other => panic!("expected LogicEvaluation, got {other:?}"),
853 }
854 }
855
856 #[test]
857 fn non_ascii_result_round_trips() {
858 let dl = engine();
859 let c = TemplateCompiler::new(Arc::clone(&dl));
860 let mut m = Message::from_value(&json!({}));
861 let ctx = TaskContext::new(&mut m, &dl);
862
863 let mut t = template_from(json!({"cat": ["über-", "größe"]}));
864 t.compile(&c, "lbl").unwrap();
865 assert_eq!(t.eval_into::<String>(&ctx).unwrap(), "über-größe");
866 }
867
868 #[test]
869 fn reading_an_absent_path_matches_the_engines_missing_path_result() {
870 let dl = engine();
871 let c = TemplateCompiler::new(Arc::clone(&dl));
872 let mut m = Message::from_value(&json!({}));
873 let ctx = TaskContext::new(&mut m, &dl);
874
875 let mut t = template_from(json!({"var": "data.nope"}));
876 t.compile(&c, "lbl").unwrap();
877 // Not an error — the same "resolves to Null" behaviour as the built-in
878 // *_logic fields on a missing path.
879 assert_eq!(t.eval(&ctx).unwrap(), OwnedDataValue::Null);
880 }
881}