Skip to main content

dataflow_rs/expr/
engine.rs

1//! `ExprEngine`: the v4 wrapper around one datalogic-rs engine.
2
3use super::arena::with_arena;
4use super::expr::{Evaluated, Expr, FromValue};
5use super::operators::{ALL_OPERATORS, Operator, OperatorFamily};
6use super::secrets::{SECRET_OPERATOR, SecretOperator, Secrets};
7use crate::base::{Error, ErrorKind, ExprCause, Result};
8use bumpalo::Bump;
9use datalogic_rs::{CustomOperator, DataValue, Engine, Logic};
10use datavalue::OwnedDataValue;
11use serde_json::Value;
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14
15/// The key prefix that escapes a template key into data: `{"$cat": …}` is the
16/// literal object, `{"cat": …}` the operator. Always on, applied at the one
17/// place an engine is built, so an engine in a test agrees with one that runs.
18pub(super) const TEMPLATE_KEY_ESCAPE: char = '$';
19
20/// Adapter handing a shared operator to the datalogic builder, which takes
21/// ownership of what it registers.
22struct SharedOperator(Arc<dyn CustomOperator>);
23
24impl CustomOperator for SharedOperator {
25    #[inline]
26    fn evaluate<'a>(
27        &self,
28        args: &[&'a datalogic_rs::DataValue<'a>],
29        ctx: &mut datalogic_rs::operator::EvalContext<'_, 'a>,
30        arena: &'a datalogic_rs::bumpalo::Bump,
31    ) -> datalogic_rs::Result<&'a datalogic_rs::DataValue<'a>> {
32        self.0.evaluate(args, ctx, arena)
33    }
34
35    fn info(&self) -> datalogic_rs::CustomOperatorInfo {
36        self.0.info()
37    }
38
39    fn check(&self, args: &[OwnedDataValue]) -> std::result::Result<(), datalogic_rs::Diagnostic> {
40        self.0.check(args)
41    }
42}
43
44mod sealed {
45    use bumpalo::Bump;
46    use datalogic_rs::{DataValue, EvalInput};
47    use datavalue::OwnedDataValue;
48    use serde_json::Value;
49
50    /// The seal, and the crate's view of a root: no datalogic type reaches
51    /// [`EvalRoot`](super::EvalRoot)'s public face.
52    pub trait Sealed<'r> {
53        /// The root as an arena value in `arena`, through datalogic's own
54        /// input adapter.
55        fn view_in<'a>(self, arena: &'a Bump) -> datalogic_rs::Result<&'a DataValue<'a>>
56        where
57            'r: 'a;
58    }
59
60    impl<'r> Sealed<'r> for &'r OwnedDataValue {
61        #[inline]
62        fn view_in<'a>(self, arena: &'a Bump) -> datalogic_rs::Result<&'a DataValue<'a>>
63        where
64            'r: 'a,
65        {
66            <&'a OwnedDataValue as EvalInput<'a>>::into_arena_value(self, arena)
67        }
68    }
69
70    impl<'r> Sealed<'r> for &'r Value {
71        #[inline]
72        fn view_in<'a>(self, arena: &'a Bump) -> datalogic_rs::Result<&'a DataValue<'a>>
73        where
74            'r: 'a,
75        {
76            <&'a Value as EvalInput<'a>>::into_arena_value(self, arena)
77        }
78    }
79}
80
81/// A root [`ExprEngine::eval`] evaluates against: an object holding the
82/// three context roots, as an [`OwnedDataValue`] or as a
83/// [`serde_json::Value`].
84///
85/// Either is viewed in place, its strings and keys borrowed and only its
86/// arrays' and objects' spines built, so a host that holds its context as
87/// JSON evaluates it without converting it first, and gets what the same
88/// context as an `OwnedDataValue` gives. Sealed: the two are the
89/// implementations.
90///
91/// ```
92/// use dataflow_rs::datavalue::OwnedDataValue;
93/// use dataflow_rs::expr::ExprEngine;
94/// use dataflow_rs::Expr;
95/// use serde_json::json;
96///
97/// let exprs = ExprEngine::default();
98/// let limit: Expr<u64> = exprs.compile(&json!({"*": [{"var": "data.n"}, 2]})).unwrap();
99/// let context = json!({"data": {"n": 21}, "metadata": {}, "temp_data": {}});
100/// assert_eq!(exprs.eval(&limit, &context).unwrap().value, 42);
101/// let owned = OwnedDataValue::from(&context);
102/// assert_eq!(exprs.eval(&limit, &owned).unwrap().value, 42);
103/// ```
104pub trait EvalRoot<'r>: sealed::Sealed<'r> {}
105
106impl<'r> EvalRoot<'r> for &'r OwnedDataValue {}
107
108impl<'r> EvalRoot<'r> for &'r Value {}
109
110struct Inner {
111    logic: Engine,
112    operators: Vec<Operator>,
113    by_name: HashMap<Box<str>, usize>,
114    secrets: Arc<Secrets>,
115}
116
117/// Compiles and evaluates the JSONLogic a definition is written in.
118///
119/// Cheap to clone: a clone shares the datalogic engine, the operator table and
120/// the secrets.
121#[derive(Clone)]
122pub struct ExprEngine {
123    inner: Arc<Inner>,
124}
125
126impl Default for ExprEngine {
127    fn default() -> Self {
128        // The builder with no operators has nothing to refuse.
129        Self::builder()
130            .build()
131            .expect("an engine with no custom operators cannot fail to build")
132    }
133}
134
135impl std::fmt::Debug for ExprEngine {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("ExprEngine")
138            .field("operators", &self.inner.operators.len())
139            .field("secrets", &self.inner.secrets)
140            .finish()
141    }
142}
143
144impl ExprEngine {
145    /// Starts a builder.
146    pub fn builder() -> ExprEngineBuilder {
147        ExprEngineBuilder::default()
148    }
149
150    /// Every operator this engine evaluates: the datalogic built-ins its
151    /// selected families enable, then the host's operators in registration
152    /// order, then `secret`.
153    pub fn operators(&self) -> &[Operator] {
154        &self.inner.operators
155    }
156
157    /// The operator named `name`, if the engine has one.
158    pub(crate) fn operator(&self, name: &str) -> Option<&Operator> {
159        self.inner
160            .by_name
161            .get(name)
162            .map(|&i| &self.inner.operators[i])
163    }
164
165    /// The secrets the `secret` operator reads.
166    pub fn secrets(&self) -> &Secrets {
167        &self.inner.secrets
168    }
169
170    /// The template-key escape: `$`.
171    pub fn escape(&self) -> char {
172        TEMPLATE_KEY_ESCAPE
173    }
174
175    /// Compiles `raw`. An expression that reads nothing is evaluated once, here,
176    /// and kept as its `T`.
177    ///
178    /// # Errors
179    ///
180    /// [`ErrorKind::Compile`] when datalogic refuses the expression;
181    /// [`ErrorKind::Eval`] when evaluating a constant at fold time fails;
182    /// [`ErrorKind::Config`] when a folded constant is not a `T`.
183    pub fn compile<T: FromValue>(&self, raw: &Value) -> Result<Expr<T>> {
184        let reads_secret = super::walk::reads_secret(raw);
185        let logic = self
186            .logic()
187            .compile(raw)
188            .map_err(|e| compile_error(e, reads_secret))?;
189        // `is_constant`, never `is_static`: the latter is also true for a rule
190        // that failed to fold because it errors, and that error belongs at eval.
191        let constant = if logic.is_constant() {
192            let folded = with_arena(|arena| {
193                self.logic()
194                    .evaluate(&logic, DataValue::Object(&[]), arena)
195                    .map_err(|e| eval_error(e, Some(&logic), None, reads_secret))
196                    .and_then(|v| {
197                        T::from_view(v).map_err(|why| {
198                            Error::new(
199                                ErrorKind::Config,
200                                format!(
201                                    "`{}` must be {}: {why}",
202                                    super::expr::snippet(raw),
203                                    T::type_name()
204                                ),
205                            )
206                            .with_expr_cause(ExprCause::type_mismatch(None))
207                        })
208                    })
209            })?;
210            Some(folded)
211        } else {
212            None
213        };
214        Ok(Expr {
215            raw: raw.clone(),
216            logic,
217            constant,
218            reads_secret,
219        })
220    }
221
222    /// Evaluates `expr` against `root`, an object holding the three context
223    /// roots, as an [`OwnedDataValue`] or a [`serde_json::Value`]
224    /// ([`EvalRoot`]). A folded constant is lent from `expr` and takes no
225    /// arena.
226    ///
227    /// # Errors
228    ///
229    /// [`ErrorKind::Budget`] when the operation ceiling is exceeded;
230    /// [`ErrorKind::Eval`] for any other failure, including a result that is
231    /// not a `T`.
232    pub fn eval<'e, 'r, T: FromValue>(
233        &self,
234        expr: &'e Expr<T>,
235        root: impl EvalRoot<'r>,
236    ) -> Result<Evaluated<T::Out<'e>>> {
237        if let Some(c) = &expr.constant {
238            return Ok(Evaluated {
239                value: T::lend(c),
240                ops: if cfg!(feature = "budget") {
241                    Some(0)
242                } else {
243                    None
244                },
245            });
246        }
247        with_arena(|arena| {
248            let root = sealed::Sealed::view_in(root, arena)
249                .map_err(|e| eval_error(e, Some(&expr.logic), None, expr.reads_secret))?;
250            let (view, ops) = self.eval_in(expr.logic(), expr.reads_secret, root, arena)?;
251            Ok(Evaluated {
252                value: T::give(expr.convert(view, ops)?),
253                ops,
254            })
255        })
256    }
257
258    /// Evaluates `expr` against a view `root` with an optional extra op ceiling,
259    /// returning both the result and the metered op count (even on `Err`):
260    /// the shared evaluation path for `SyncCtx`, `Ctx` and `map`'s destination.
261    pub(crate) fn eval_view_metered<'e, T: FromValue>(
262        &self,
263        expr: &'e Expr<T>,
264        root: &DataValue<'_>,
265        arena: &Bump,
266        ops_limit: Option<u64>,
267    ) -> (Result<T::Out<'e>>, Option<u64>) {
268        if let Some(c) = expr.constant() {
269            let ops = if cfg!(feature = "budget") {
270                Some(0)
271            } else {
272                None
273            };
274            return (Ok(T::lend(c)), ops);
275        }
276        let (res, ops) =
277            self.eval_in_metered(expr.logic(), expr.reads_secret, root, arena, ops_limit);
278        let out = res.and_then(|view| expr.convert(view, ops).map(T::give));
279        (out, ops)
280    }
281
282    /// Evaluates `logic` against a view root, allocating in `arena`: how a
283    /// built-in reads the run view, and how `map` evaluates in the run arena
284    /// so its result splices without a copy.
285    ///
286    /// # Errors
287    ///
288    /// [`ErrorKind::Budget`] when the operation ceiling is exceeded;
289    /// [`ErrorKind::Eval`] for any other failure (the classifier
290    /// [`Self::eval`] uses).
291    pub(crate) fn eval_in<'a>(
292        &self,
293        logic: &'a Logic,
294        reads_secret: bool,
295        root: &'a DataValue<'a>,
296        arena: &'a Bump,
297    ) -> Result<(&'a DataValue<'a>, Option<u64>)> {
298        let (res, ops) = self.eval_in_metered(logic, reads_secret, root, arena, None);
299        res.map(|v| (v, ops))
300    }
301
302    /// Evaluates `logic` against a view root with an optional extra op ceiling,
303    /// returning both the result and the metered op count (even on `Err`).
304    pub(crate) fn eval_in_metered<'a>(
305        &self,
306        logic: &'a Logic,
307        reads_secret: bool,
308        root: &'a DataValue<'a>,
309        arena: &'a Bump,
310        ops_limit: Option<u64>,
311    ) -> (Result<&'a DataValue<'a>>, Option<u64>) {
312        #[cfg(feature = "budget")]
313        {
314            let engine_budget = self.logic().resolve_ops_budget(None);
315            let budget = match ops_limit {
316                Some(b) => engine_budget.min(b),
317                None => engine_budget,
318            };
319            let report = self
320                .logic()
321                .evaluate_metered_report(logic, root, arena, budget);
322            let ops = Some(report.ops);
323            let res = report
324                .value
325                .map_err(|e| eval_error(e, Some(logic), ops, reads_secret));
326            (res, ops)
327        }
328        #[cfg(not(feature = "budget"))]
329        {
330            let _ = ops_limit;
331            let res = self
332                .logic()
333                .evaluate(logic, root, arena)
334                .map_err(|e| eval_error(e, Some(logic), None, reads_secret));
335            (res, None)
336        }
337    }
338
339    /// The datalogic engine underneath.
340    pub(crate) fn logic(&self) -> &Engine {
341        &self.inner.logic
342    }
343}
344
345/// Converts a compile-time `datalogic_rs::Error` into [`Error`], preserving
346/// both the typed source and the structured [`ExprCause`].
347fn compile_error(e: datalogic_rs::Error, reads_secret: bool) -> Error {
348    let e = if reads_secret {
349        sanitize_secret_error(e)
350    } else {
351        e
352    };
353    let cause = ExprCause::from_datalogic(&e, None, None);
354    let msg = e.to_string();
355    Error::new(ErrorKind::Compile, msg)
356        .with_source(e)
357        .with_expr_cause(cause)
358}
359
360/// Classifies a datalogic evaluation failure: a spent budget is its own kind.
361fn eval_error(
362    e: datalogic_rs::Error,
363    logic: Option<&Logic>,
364    ops: Option<u64>,
365    reads_secret: bool,
366) -> Error {
367    let e = if reads_secret {
368        sanitize_secret_error(e)
369    } else {
370        e
371    };
372    let kind = if matches!(e.kind, datalogic_rs::ErrorKind::BudgetExceeded { .. }) {
373        ErrorKind::Budget
374    } else {
375        ErrorKind::Eval
376    };
377    let cause = ExprCause::from_datalogic(&e, logic, ops);
378    let msg = e.to_string();
379    Error::new(kind, msg).with_source(e).with_expr_cause(cause)
380}
381
382/// Redacts payload-bearing variants of a `datalogic_rs::Error` produced by an
383/// expression that reads `$secret`, preventing secret values from leaking via
384/// `throw`, parse errors, or custom operator messages.
385fn sanitize_secret_error(mut e: datalogic_rs::Error) -> datalogic_rs::Error {
386    match &mut e.kind {
387        datalogic_rs::ErrorKind::Thrown(v) => {
388            *v = OwnedDataValue::Null;
389        }
390        datalogic_rs::ErrorKind::ParseError(s)
391        | datalogic_rs::ErrorKind::FormatError(s)
392        | datalogic_rs::ErrorKind::TypeError(s) => {
393            *s = std::borrow::Cow::Borrowed("<redacted>");
394        }
395        datalogic_rs::ErrorKind::Custom(_) => {
396            let op = e.operator().map(str::to_owned);
397            let ids = e.node_ids().to_vec();
398            let mut redacted = datalogic_rs::Error::custom_message("<redacted>");
399            if let Some(op) = op {
400                redacted = redacted.with_operator(op);
401            }
402            if !ids.is_empty() {
403                redacted = redacted.with_node_ids(ids);
404            }
405            return redacted;
406        }
407        _ => {}
408    }
409    e
410}
411
412/// Builds an [`ExprEngine`].
413///
414/// Defaults to [`OperatorFamily::Core`] (and [`OperatorFamily::Secret`]) only.
415/// Select optional operator families at runtime with [`family`](Self::family),
416/// [`families`](Self::families) or [`all_operators`](Self::all_operators).
417#[derive(Default)]
418pub struct ExprEngineBuilder {
419    families: Vec<OperatorFamily>,
420    operators: Vec<(Operator, Arc<dyn CustomOperator>)>,
421    secrets: Secrets,
422    #[cfg(feature = "budget")]
423    ops_budget: Option<u64>,
424}
425
426impl std::fmt::Debug for ExprEngineBuilder {
427    /// The selected families, the custom operators' names and the secrets, redacted.
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        let operators: Vec<&str> = self.operators.iter().map(|(info, _)| &*info.name).collect();
430        f.debug_struct("ExprEngineBuilder")
431            .field("families", &self.families)
432            .field("operators", &operators)
433            .field("secrets", &self.secrets)
434            .finish_non_exhaustive()
435    }
436}
437
438impl ExprEngineBuilder {
439    /// Enables one built-in [`OperatorFamily`].
440    ///
441    /// Enabling a family is not a no-op for existing workflows: under
442    /// templating mode an unknown operator name passes through as literal data,
443    /// so `{"length": ...}` starts evaluating once
444    /// [`OperatorFamily::ExtString`] is enabled (`{"$length": ...}` pins the
445    /// literal). [`OperatorFamily::Datetime`] and [`OperatorFamily::Tensor`]
446    /// also require their cargo features (`datetime`, `tensor`) to be enabled.
447    #[must_use]
448    pub fn family(mut self, family: OperatorFamily) -> Self {
449        self.families.push(family);
450        self
451    }
452
453    /// Enables each built-in [`OperatorFamily`] in `families`.
454    #[must_use]
455    pub fn families(mut self, families: impl IntoIterator<Item = OperatorFamily>) -> Self {
456        self.families.extend(families);
457        self
458    }
459
460    /// Enables the standard optional operator families:
461    /// [`ExtString`](OperatorFamily::ExtString),
462    /// [`ExtArray`](OperatorFamily::ExtArray),
463    /// [`ExtMath`](OperatorFamily::ExtMath),
464    /// [`ExtControl`](OperatorFamily::ExtControl),
465    /// [`ExtObject`](OperatorFamily::ExtObject),
466    /// [`ErrorHandling`](OperatorFamily::ErrorHandling), and (when the
467    /// `datetime` cargo feature is enabled)
468    /// [`Datetime`](OperatorFamily::Datetime).
469    ///
470    /// [`OperatorFamily::Tensor`] is deliberately left out: names like
471    /// `shape`, `full`, `cast` and `concat` are ordinary JSON keys, which a
472    /// live operator would start evaluating.
473    #[must_use]
474    pub fn all_operators(self) -> Self {
475        self.families(ALL_OPERATORS.iter().copied())
476    }
477
478    /// Registers a custom operator, neither scoping nor deterministic.
479    ///
480    /// # Dependency
481    ///
482    /// `op` is a [`datalogic_rs::CustomOperator`](crate::datalogic_rs::CustomOperator),
483    /// named through this crate's re-export. It is part of the committed
484    /// dependency surface, as [`OwnedDataValue`](datavalue::OwnedDataValue)
485    /// is: wrapping it would still take datalogic's value and arena types, so
486    /// the engine keeps the trait itself.
487    #[must_use]
488    pub fn operator(self, name: impl Into<String>, op: impl CustomOperator + 'static) -> Self {
489        self.operator_with(Operator::custom(name.into()), op)
490    }
491
492    /// Registers a custom operator with its own flags. The family is always
493    /// [`OperatorFamily::Custom`]; `op` is a
494    /// [`datalogic_rs::CustomOperator`](crate::datalogic_rs::CustomOperator),
495    /// the committed dependency type [`operator`](Self::operator) documents.
496    #[must_use]
497    pub fn operator_with(mut self, mut info: Operator, op: impl CustomOperator + 'static) -> Self {
498        info.family = OperatorFamily::Custom;
499        self.operators.push((info, Arc::new(op)));
500        self
501    }
502
503    /// Sets the secrets the `secret` operator reads.
504    #[must_use]
505    pub fn secrets(mut self, secrets: Secrets) -> Self {
506        self.secrets = secrets;
507        self
508    }
509
510    /// Sets the ceiling on operations per evaluation.
511    #[cfg(feature = "budget")]
512    #[must_use]
513    pub fn ops_budget(mut self, ops: u64) -> Self {
514        self.ops_budget = Some(ops);
515        self
516    }
517
518    /// Builds the engine, with [`OperatorFamily::Core`],
519    /// [`OperatorFamily::Secret`] and the families selected on this builder,
520    /// and no other. [`ErrorKind::Config`] when [`OperatorFamily::Custom`] or
521    /// an uncompiled cargo-feature family (`Datetime`, `Tensor`) is requested,
522    /// or when a custom operator is named `secret`, is named like a built-in
523    /// operator of the selected families, or is registered twice.
524    pub fn build(self) -> Result<ExprEngine> {
525        let mut upstream_families = Vec::with_capacity(self.families.len());
526        for family in self.families {
527            match family {
528                OperatorFamily::Core | OperatorFamily::Secret => {}
529                OperatorFamily::Custom => {
530                    return Err(Error::new(
531                        ErrorKind::Config,
532                        "custom operators are registered with `operator`, not `family`",
533                    ));
534                }
535                other => {
536                    let upstream = other
537                        .to_upstream()
538                        .expect("every built-in family maps to a datalogic family");
539                    if !upstream.is_compiled() {
540                        return Err(Error::new(
541                            ErrorKind::Config,
542                            format!(
543                                "operator family `{other:?}` requires its cargo feature to be enabled"
544                            ),
545                        ));
546                    }
547                    upstream_families.push(upstream);
548                }
549            }
550        }
551        let secrets = Arc::new(self.secrets);
552        let mut builder = Engine::builder()
553            .with_families(upstream_families)
554            .with_templating(true)
555            .with_template_key_escape(TEMPLATE_KEY_ESCAPE);
556        // `with_config` replaces the whole `EvaluationConfig`, and `default()`
557        // is what the builder already holds, so an unset ceiling is a no-op.
558        #[cfg(feature = "budget")]
559        {
560            builder = builder.with_config(
561                datalogic_rs::EvaluationConfig::default().with_ops_budget(self.ops_budget),
562            );
563        }
564        let mut infos = Vec::with_capacity(self.operators.len());
565        for (info, op) in self.operators {
566            builder = builder.add_operator(info.name.to_string(), SharedOperator(op));
567            infos.push(info);
568        }
569        builder = builder.add_operator(SECRET_OPERATOR, SecretOperator(Arc::clone(&secrets)));
570        let logic = builder.build();
571
572        // A custom name is judged against the built-ins this engine has, so
573        // no throwaway engine is built for it.
574        let builtin: HashSet<&'static str> = logic.builtin_operator_names().collect();
575        let mut seen = HashSet::new();
576        for info in &infos {
577            let name = &*info.name;
578            let refusal = if name == SECRET_OPERATOR {
579                Some("is reserved for the engine's own operator")
580            } else if builtin.contains(name) {
581                Some("is the name of a built-in operator")
582            } else if !seen.insert(name) {
583                Some("is registered twice")
584            } else {
585                None
586            };
587            if let Some(why) = refusal {
588                return Err(Error::new(
589                    ErrorKind::Config,
590                    format!("operator `{name}` {why}"),
591                ));
592            }
593        }
594
595        // datalogic's own description of its operators, in its order, one
596        // row per name.
597        let mut operators: Vec<Operator> =
598            logic.operators().flat_map(Operator::from_logic).collect();
599        operators.extend(infos);
600        operators.push(Operator::secret());
601        let by_name: HashMap<Box<str>, usize> = operators
602            .iter()
603            .enumerate()
604            .map(|(i, op)| (Box::from(&*op.name), i))
605            .collect();
606
607        Ok(ExprEngine {
608            inner: Arc::new(Inner {
609                logic,
610                operators,
611                by_name,
612                secrets,
613            }),
614        })
615    }
616}
617
618#[cfg(test)]
619impl ExprEngine {
620    /// Compiles `logic` and evaluates it against an empty object.
621    pub(crate) fn try_eval_json(
622        &self,
623        logic: &serde_json::Value,
624    ) -> std::result::Result<serde_json::Value, datalogic_rs::Error> {
625        let compiled = self.logic().compile_arc(logic).expect("should compile");
626        let ctx = datavalue::OwnedDataValue::from(&serde_json::json!({}));
627        let text = self.logic().session().eval_str(&compiled, &ctx)?;
628        Ok(serde_json::from_str(&text).expect("eval_str output should be valid JSON"))
629    }
630
631    /// As [`Self::try_eval_json`], panicking on an evaluation error.
632    pub(crate) fn eval_json(&self, logic: &serde_json::Value) -> serde_json::Value {
633        self.try_eval_json(logic).expect("should evaluate")
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use serde_json::json;
641
642    #[test]
643    fn empty_operand_results_this_crate_would_silently_break_on() {
644        // A workflow author can write any of these — a map mapping folding an
645        // empty list, a filter condition over an empty selector — and the
646        // crate never validates operand count. If a datalogic upgrade changed
647        // any of these defaults, every workflow relying on the vacuous case
648        // would silently start producing a different value.
649        let e = ExprEngine::default();
650        for (logic, expected) in [
651            (json!({"and": []}), json!(null)),
652            (json!({"or": []}), json!(null)),
653            (json!({"+": []}), json!(0)),
654            (json!({"*": []}), json!(1)),
655            (json!({"cat": []}), json!("")),
656            (json!({"merge": []}), json!([])),
657            (json!({"missing": []}), json!([])),
658        ] {
659            assert_eq!(e.eval_json(&logic), expected, "for {logic}");
660        }
661    }
662
663    #[test]
664    fn a_missing_var_path_resolves_to_null_not_an_error() {
665        // The exact mechanism behind the pitfall CLAUDE.md documents for
666        // `payload.*` expressions: a `var` over a path that does not resolve
667        // is `Null`, silently, never `Err`. `Expr` and the built-in
668        // expression fields inherit this — there is no engine-level signal that
669        // distinguishes "field absent" from "field is null".
670        let e = ExprEngine::default();
671        assert_eq!(
672            e.eval_json(&json!({"var": "data.does_not_exist"})),
673            json!(null)
674        );
675    }
676
677    #[test]
678    fn truthy_falsy_matches_the_documented_semantics() {
679        // Verifies the claim in docs/src/advanced/jsonlogic.md's Truthy/Falsy
680        // section, which is a `json` fence and therefore NOT compiled by
681        // dataflow-docs-tests — this is the only check on that claim.
682        // Notable and easy to get wrong: an empty object `{}` is falsy here,
683        // unlike some JSONLogic implementations that treat any object as truthy.
684        let e = ExprEngine::default();
685        for (v, truthy) in [
686            (json!(0), false),
687            (json!(""), false),
688            (json!(false), false),
689            (json!(null), false),
690            (json!([]), false),
691            (json!({}), false),
692            (json!("x"), true),
693            (json!(1), true),
694        ] {
695            assert_eq!(
696                e.eval_json(&json!({"!!": v})),
697                json!(truthy),
698                "truthiness of {v}"
699            );
700        }
701    }
702
703    #[test]
704    fn an_unrecognised_operator_is_not_an_error_under_templating() {
705        // The load-bearing fact behind #26's refusal of a static "known
706        // operators" table: under JSONLogic templating (which `ExprEngine`
707        // enables), an outright typo neither fails to compile nor fails to
708        // evaluate. It echoes back as a literal structured object instead — a
709        // workflow author who mistypes an operator name gets silent
710        // pass-through, not a validation error. True under every feature
711        // combination, so this half of the tripwire is unconditional.
712        let e = ExprEngine::default();
713        let logic = json!({"totally_made_up_op_xyz": ["a", "b"]});
714        assert_eq!(
715            e.eval_json(&logic),
716            logic,
717            "an unrecognised operator must echo back verbatim, not error"
718        );
719    }
720
721    /// With `ExtString` off (the default), `starts_with` is not a name the
722    /// engine knows, so it is indistinguishable from the typo above: silent
723    /// pass-through. This is the failure mode a workflow author hits when they
724    /// reach for an operator whose family the engine did not enable — no error,
725    /// just a wrong value.
726    #[test]
727    fn a_gated_operator_echoes_back_while_its_family_is_off() {
728        let e = ExprEngine::default();
729        let logic = json!({"starts_with": ["hello", "he"]});
730        assert_eq!(
731            e.eval_json(&logic),
732            logic,
733            "an operator behind an unenabled family must echo back, not error"
734        );
735    }
736
737    /// The other side of the same coin, and the reason enabling a family is
738    /// not a no-op for existing workflows: `ExtString` converts a previously
739    /// inert `{"starts_with": [...]}` *literal* into a live operator call.
740    /// Anyone carrying such an object as data through a `map` mapping sees
741    /// their value silently replaced by the operator's result.
742    #[test]
743    fn a_gated_operator_evaluates_once_its_family_is_on() {
744        let e = ExprEngine::builder()
745            .family(OperatorFamily::ExtString)
746            .build()
747            .unwrap();
748        assert_eq!(
749            e.eval_json(&json!({"starts_with": ["hello", "he"]})),
750            json!(true),
751            "with ExtString on, starts_with must evaluate, not echo"
752        );
753    }
754
755    /// `datetime` is the one family that is not confined to new operator
756    /// names. `datalogic-rs`'s comparison path probes *plain strings* for a
757    /// datetime/duration shape before falling back to byte comparison, so
758    /// `==` and the ordering operators change answers on date-shaped
759    /// operands. These two strings are different byte sequences naming the
760    /// same instant.
761    #[test]
762    fn datetime_feature_changes_plain_string_comparison() {
763        let e = ExprEngine::default();
764        let logic = json!({"==": ["2024-01-15T00:00:00Z", "2024-01-15T01:00:00+01:00"]});
765        #[cfg(feature = "datetime")]
766        assert_eq!(e.eval_json(&logic), json!(true));
767        #[cfg(not(feature = "datetime"))]
768        assert_eq!(e.eval_json(&logic), json!(false));
769    }
770
771    /// Each family selected on `ExprEngineBuilder` reaches `datalogic-rs`. One
772    /// representative operator per family is enough.
773    #[test]
774    fn ext_string_family_reaches_datalogic() {
775        let e = ExprEngine::builder()
776            .family(OperatorFamily::ExtString)
777            .build()
778            .unwrap();
779        assert_eq!(e.eval_json(&json!({"upper": "ab"})), json!("AB"));
780    }
781
782    #[test]
783    fn ext_array_family_reaches_datalogic() {
784        let e = ExprEngine::builder()
785            .family(OperatorFamily::ExtArray)
786            .build()
787            .unwrap();
788        assert_eq!(e.eval_json(&json!({"sort": [[3, 1, 2]]})), json!([1, 2, 3]));
789    }
790
791    #[test]
792    fn ext_math_family_reaches_datalogic() {
793        let e = ExprEngine::builder()
794            .family(OperatorFamily::ExtMath)
795            .build()
796            .unwrap();
797        assert_eq!(e.eval_json(&json!({"abs": -5})), json!(5));
798    }
799
800    #[test]
801    fn ext_control_family_reaches_datalogic() {
802        let e = ExprEngine::builder()
803            .family(OperatorFamily::ExtControl)
804            .build()
805            .unwrap();
806        assert_eq!(
807            e.eval_json(&json!({"??": [null, "fallback"]})),
808            json!("fallback")
809        );
810    }
811
812    #[test]
813    fn ext_object_family_reaches_datalogic() {
814        let e = ExprEngine::builder()
815            .family(OperatorFamily::ExtObject)
816            .build()
817            .unwrap();
818        assert_eq!(
819            e.eval_json(&json!({"keys": [{"a": 1, "b": 2}]})),
820            json!(["a", "b"])
821        );
822    }
823
824    #[test]
825    fn error_handling_family_reaches_datalogic() {
826        // `ErrorHandling` is the JSONLogic `try`/`throw` pair — unrelated to
827        // this crate's own always-on error handling.
828        let e = ExprEngine::builder()
829            .family(OperatorFamily::ErrorHandling)
830            .build()
831            .unwrap();
832        assert_eq!(
833            e.eval_json(&json!({"try": [{"throw": "boom"}, "recovered"]})),
834            json!("recovered")
835        );
836    }
837
838    /// The `datetime` family's own operators. Their exact output depends on
839    /// the ambient clock and on format details this crate does not pin, so
840    /// assert only the property the family actually buys: the operator is
841    /// recognised and evaluates once `OperatorFamily::Datetime` is selected,
842    /// and echoes back as a literal on `ExprEngine::default()`.
843    #[cfg(feature = "datetime")]
844    #[test]
845    fn datetime_family_reaches_datalogic() {
846        let logic = json!({"now": []});
847        assert_eq!(
848            ExprEngine::default().eval_json(&logic),
849            logic,
850            "without OperatorFamily::Datetime selected, `now` echoes back"
851        );
852        let e = ExprEngine::builder()
853            .family(OperatorFamily::Datetime)
854            .build()
855            .unwrap();
856        let result = e.eval_json(&logic);
857        assert_ne!(result, logic, "with Datetime on, `now` must not echo back");
858        assert!(!result.is_null(), "`now` should produce a value, got null");
859    }
860    #[cfg(feature = "budget")]
861    #[test]
862    fn the_ops_budget_stops_a_map_over_three_items() {
863        let e = ExprEngine::builder().ops_budget(2).build().unwrap();
864        let logic = json!({"map": [[1, 2, 3], {"+": [{"var": ""}, 1]}]});
865        let err = e.try_eval_json(&logic).unwrap_err();
866        assert_eq!(err.tag(), "BudgetExceeded");
867        // Without a ceiling the same expression runs.
868        assert_eq!(ExprEngine::default().eval_json(&logic), json!([2, 3, 4]));
869    }
870
871    #[test]
872    fn escape_is_dollar_and_the_escaped_key_is_literal() {
873        let e = ExprEngine::default();
874        assert_eq!(e.escape(), '$');
875        assert_eq!(e.eval_json(&json!({"$cat": [1]})), json!({"cat": [1]}));
876    }
877}