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