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}