Skip to main content

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