Skip to main content

datalogic_rs/node/
logic.rs

1//! `Logic` — the compiled, thread-safe rule snapshot returned by
2//! `Engine::compile`. Includes the static-evaluation predicates the compiler
3//! consults to decide whether a sub-expression can be folded.
4
5use super::{CompiledNode, populate_lits};
6use crate::arena::DataValue;
7use crate::opcode::OpCode;
8use datavalue::OwnedDataValue;
9
10/// Compiled logic that can be evaluated multiple times across different data.
11///
12/// `Logic` represents a pre-processed JSONLogic expression that has been
13/// optimized for repeated evaluation. It's thread-safe and can be shared across
14/// threads using `Arc`.
15///
16/// # Performance Benefits
17///
18/// - **Parse once, evaluate many**: Avoid repeated JSON parsing
19/// - **Static evaluation**: Constant expressions are pre-computed
20/// - **OpCode dispatch**: Built-in operators use fast enum dispatch
21/// - **Thread-safe sharing**: Use `Arc` to share across threads
22///
23/// # Example
24///
25/// ```rust
26/// use std::sync::Arc;
27/// use datalogic_rs::Engine;
28///
29/// let engine = Engine::new();
30/// let compiled = Arc::new(engine.compile(r#"{">": [{"var": "score"}, 90]}"#).unwrap());
31///
32/// // Compiled logic can be cloned cheaply (atomic refcount) and sent across threads.
33/// let compiled_clone = Arc::clone(&compiled);
34/// std::thread::spawn(move || {
35///     let engine = Engine::new();
36///     let _result = engine
37///         .session()
38///         .eval_str(&compiled_clone, r#"{"score": 95}"#)
39///         .unwrap();
40/// });
41/// ```
42///
43/// `Logic` is `Clone` (deep-clones the compiled tree). Cloning is the right
44/// choice when a caller needs an independently mutable copy or wants to
45/// store the rule by value; for sharing the *same* compiled rule across
46/// threads or evaluations, prefer `Arc<Logic>` — the `Arc::clone` is a
47/// single atomic refcount bump rather than a tree walk.
48#[derive(Clone)]
49pub struct Logic {
50    /// The root node of the compiled logic tree.
51    pub(crate) root: CompiledNode,
52    /// Conservative upper bound on the static portion of arena allocations
53    /// this rule will need (literals, structured-object skeletons, etc.).
54    /// Used to size the per-call `Bump` so the first chunk is large enough.
55    /// `pub(crate)` — internal arena infrastructure.
56    pub(crate) arena_static_bytes: usize,
57    /// Pre-resolved operator name for the root node, attached to every
58    /// `Error` returned from the public `evaluate*` API. Cached at compile
59    /// time so the error-unwind path does no tree walk. `Cow::Borrowed`
60    /// for built-ins (zero alloc on attach), `Cow::Owned` for
61    /// `CustomOperator` (one alloc per compile, amortised over many
62    /// evaluations), `None` for `Value` literals.
63    pub(crate) root_op_name: Option<std::borrow::Cow<'static, str>>,
64}
65
66impl std::fmt::Debug for Logic {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.debug_struct("Logic")
69            .field("root", &self.root)
70            .field("arena_static_bytes", &self.arena_static_bytes)
71            .field("root_op_name", &self.root_op_name)
72            .finish_non_exhaustive()
73    }
74}
75
76/// Static operator-name lookup for the root node. Returns `Cow::Borrowed`
77/// for built-ins and the named compiled-node forms (`var`, `missing`,
78/// etc.) — these never allocate at compile time. `CustomOperator`
79/// returns `Cow::Owned` (one allocation per compile, then re-cloneable as
80/// many times as the rule errors). `Value` literals have no operator and
81/// return `None`.
82#[inline]
83fn root_op_name(node: &CompiledNode) -> Option<std::borrow::Cow<'static, str>> {
84    use std::borrow::Cow;
85    match node {
86        CompiledNode::BuiltinOperator { opcode, .. } => Some(Cow::Borrowed(opcode.as_str())),
87        CompiledNode::Var { .. } => Some(Cow::Borrowed("var")),
88        CompiledNode::Missing(_) => Some(Cow::Borrowed("missing")),
89        CompiledNode::MissingSome(_) => Some(Cow::Borrowed("missing_some")),
90        #[cfg(feature = "ext-control")]
91        CompiledNode::Exists(_) => Some(Cow::Borrowed("exists")),
92        #[cfg(feature = "error-handling")]
93        CompiledNode::Throw(_) => Some(Cow::Borrowed("throw")),
94        CompiledNode::CustomOperator(data) => Some(Cow::Owned(data.name.clone())),
95        CompiledNode::InvalidArgs { op_name, .. } => Some(Cow::Borrowed(op_name)),
96        _ => None,
97    }
98}
99
100impl Logic {
101    /// Creates a new compiled logic from a root node.
102    ///
103    /// Caches per-operator analysis results onto every `BuiltinOperator`
104    /// node. Trivial literals (Null/Bool/Number/empty) are pre-built by
105    /// [`super::populate::precompute_lit`] at construction; non-trivial literals
106    /// (non-empty Strings/Arrays/Objects) fall through to `literal_fallback`
107    /// at dispatch time.
108    ///
109    /// # Arguments
110    ///
111    /// * `root` - The root node of the compiled logic tree
112    pub(crate) fn new(mut root: CompiledNode) -> Self {
113        let arena_static_bytes = estimate_arena_static_bytes(&root);
114        populate_lits(&mut root);
115        let root_op_name = root_op_name(&root);
116        Self {
117            root,
118            arena_static_bytes,
119            root_op_name,
120        }
121    }
122
123    /// Check if this compiled logic is static (can be evaluated without context)
124    pub fn is_static(&self) -> bool {
125        node_is_static(&self.root)
126    }
127
128    /// Reconstruct a JSONLogic string from this compiled tree.
129    ///
130    /// Reflects the *compiled* shape — constant-folded sub-expressions
131    /// appear as literals, since the original operator is gone by then.
132    /// Re-parsing the output through [`crate::Engine::compile`] yields a
133    /// `Logic` that evaluates identically. Useful for caching keys, identity
134    /// checks across compiled rules, debug logging, and tooling.
135    ///
136    /// `Var` nodes serialise to `{"var": "..."}` for `scope_level == 0`
137    /// and to `{"val": [[<level>], ...]}` for `scope_level > 0` — that's
138    /// the shape the compiler accepts on round-trip.
139    ///
140    /// # Example
141    ///
142    /// ```rust
143    /// use datalogic_rs::Engine;
144    ///
145    /// let engine = Engine::new();
146    /// let compiled = engine.compile(r#"{">": [{"var": "score"}, 90]}"#).unwrap();
147    /// let json = compiled.to_json();
148    /// assert!(json.contains(r#""var": "score""#));
149    ///
150    /// // Round-trip: re-compiling the output produces an equivalent rule.
151    /// let recompiled = engine.compile(&json).unwrap();
152    /// assert_eq!(
153    ///     engine.eval_str(&json, r#"{"score": 95}"#).unwrap(),
154    ///     "true",
155    /// );
156    /// # let _ = (compiled, recompiled);
157    /// ```
158    pub fn to_json(&self) -> String {
159        crate::node_serialize::node_to_json_string(&self.root)
160    }
161}
162
163impl std::fmt::Display for Logic {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(&self.to_json())
166    }
167}
168
169/// Estimate the static (rule-dependent, data-independent) portion of arena
170/// bytes this rule will need at evaluation time. Conservative — overestimating
171/// is harmless (one larger bumpalo chunk), underestimating costs an extra
172/// chunk allocation. Data-dependent allocations (filter results, map outputs)
173/// can't be predicted here.
174fn estimate_arena_static_bytes(node: &CompiledNode) -> usize {
175    // Base cost per node when promoted to `DataValue`: the enum itself plus
176    // a slice-header fudge for nodes whose payload lives as `&[…]` in the
177    // arena (Array, Object, structured-object fields). The DataValue-size
178    // term tracks layout changes automatically — without datetime it's
179    // typically 24 bytes (8-byte discriminant + 16-byte fat-pointer
180    // payload), with datetime it grows to fit `DataDateTime`. String
181    // content for literals is added separately by `estimate_value_bytes`.
182    const PER_NODE: usize =
183        std::mem::size_of::<DataValue<'static>>() + std::mem::size_of::<&[u8]>();
184    let mut bytes = PER_NODE;
185
186    // Per-variant size contributions that aren't covered by recursing into
187    // AST children (literal payloads, structured-object key strings, etc.).
188    match node {
189        CompiledNode::Value { value, .. } => {
190            bytes += estimate_value_bytes(value);
191        }
192        #[cfg(feature = "error-handling")]
193        CompiledNode::Throw(data) => {
194            bytes += estimate_value_bytes(&data.error);
195        }
196        #[cfg(feature = "templating")]
197        CompiledNode::StructuredObject(data) => {
198            for (k, _) in data.fields.iter() {
199                bytes += k.len();
200            }
201        }
202        _ => {}
203    }
204
205    // Recurse into AST children via the shared visitor — single source of
206    // truth for "what are this node's children".
207    node.visit_children(&mut |child| {
208        bytes += estimate_arena_static_bytes(child);
209    });
210
211    bytes
212}
213
214fn estimate_value_bytes(v: &OwnedDataValue) -> usize {
215    match v {
216        OwnedDataValue::String(s) => s.len() + 16,
217        OwnedDataValue::Array(arr) => 16 + arr.iter().map(estimate_value_bytes).sum::<usize>(),
218        OwnedDataValue::Object(pairs) => {
219            16 + pairs
220                .iter()
221                .map(|(k, v)| k.len() + estimate_value_bytes(v))
222                .sum::<usize>()
223        }
224        _ => 0,
225    }
226}
227
228/// Check if a compiled node is static (can be evaluated without runtime context).
229pub(crate) fn node_is_static(node: &CompiledNode) -> bool {
230    match node {
231        CompiledNode::Value { .. } => true,
232        CompiledNode::Array { nodes, .. } => nodes.iter().all(node_is_static),
233        CompiledNode::BuiltinOperator { opcode, args, .. } => opcode_is_static(opcode, args),
234        CompiledNode::CustomOperator(_) => false,
235        CompiledNode::Var { .. } => false,
236        #[cfg(feature = "ext-control")]
237        CompiledNode::Exists(_) => false,
238        #[cfg(feature = "error-handling")]
239        CompiledNode::Throw(_) => false,
240        #[cfg(feature = "templating")]
241        CompiledNode::StructuredObject(data) => {
242            data.fields.iter().all(|(_, node)| node_is_static(node))
243        }
244        CompiledNode::Missing(_) | CompiledNode::MissingSome(_) => false,
245        // InvalidArgs is dynamic — it raises an error at runtime.
246        CompiledNode::InvalidArgs { .. } => false,
247    }
248}
249
250/// Check if an operator can be statically evaluated at compile time.
251///
252/// Static operators can be pre-computed during compilation when their arguments
253/// are also static, eliminating runtime evaluation overhead.
254///
255/// # Classification Criteria
256///
257/// An operator is **non-static** (dynamic) if it:
258/// 1. Reads from the data context (`var`, `val`, `missing`, `exists`)
259/// 2. Uses iterative callbacks with changing context (`map`, `filter`, `reduce`)
260/// 3. Has side effects or error handling (`try`, `throw`)
261/// 4. Depends on runtime state (`now` for current time)
262/// 5. Needs runtime disambiguation (`merge`, `min`, `max`)
263///
264/// All other operators are **static** when their arguments are static.
265fn opcode_is_static(opcode: &OpCode, args: &[CompiledNode]) -> bool {
266    use OpCode::*;
267
268    // Check if all arguments are static first (common pattern)
269    let args_static = || args.iter().all(node_is_static);
270
271    match opcode {
272        // Context-dependent: These operators read from the data context, which is
273        // not available at compile time. They must remain dynamic.
274        Val | Missing | MissingSome => false,
275        #[cfg(feature = "ext-control")]
276        Exists => false,
277
278        // Iteration operators: These push new contexts for each iteration and use
279        // callbacks that may reference the iteration variable. Even with static
280        // arrays, the callback logic depends on the per-element context.
281        Map | Filter | Reduce | All | Some | None => false,
282
283        // Error handling: These have control flow effects (early exit, error propagation)
284        // that should be preserved for runtime execution.
285        #[cfg(feature = "error-handling")]
286        Try | Throw => false,
287
288        // Time-dependent: Returns current UTC time, inherently non-static.
289        #[cfg(feature = "datetime")]
290        Now => false,
291
292        // Context-dependent in implicit form: when the bucketing
293        // expression is omitted, `fractional` reads `$flagd.flagKey` and
294        // `targetingKey` from the root data, so it cannot be folded even
295        // if every literal arg is static. Even the explicit form depends
296        // on the user expecting it to be evaluated per-call (the same
297        // input always produces the same output, but folding bakes in
298        // *one* bucketing key for the lifetime of the compiled rule —
299        // which is correct, but surprises users who rebuild the rule
300        // with a different bucketing strategy). Keep dynamic.
301        #[cfg(feature = "flagd")]
302        Fractional => false,
303        // `sem_ver` is pure given static args — `Version::parse` +
304        // comparison has no context dependency. Fold when every arg
305        // (version1, op, version2) is a literal. The common case is
306        // `sem_ver(var("app_version"), ">=", "1.2.0")` which has a
307        // dynamic var and stays dynamic naturally.
308        #[cfg(feature = "flagd")]
309        SemVer => args_static(),
310
311        // Runtime disambiguation needed: Merge/Min/Max have to distinguish
312        // a [1,2,3] literal from operator arguments at runtime to handle
313        // nested arrays correctly.
314        Merge | Min | Max => false,
315
316        // Pure operators: Static when all arguments are static. These perform
317        // deterministic transformations without side effects or context access.
318        _ => args_static(),
319    }
320}