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