Skip to main content

dataflow_rs/engine/functions/
path_template.rs

1//! # PathTemplate
2//!
3//! A config field naming a *write destination* — where a value lands in the
4//! message context — expressed as JSONLogic.
5//!
6//! Split from [`Template`] because a destination needs a different resolved
7//! form than a value does. Every write in this crate goes through the
8//! `*_parts` tree walkers, which take a pre-split `&[Arc<str>]`; the dotted
9//! string is carried alongside because [`Change::path`](crate::engine::message::Change)
10//! records it on the audit trail. Splitting per write cost measurable
11//! throughput on the `map` hot path, which is why the split has always been
12//! precomputed at engine construction.
13//!
14//! That precompute is preserved exactly: a destination authored the static way
15//! — `"data.output"` — folds to a constant, and its `(dotted, parts)` pair is
16//! computed once at compile time and handed back as two refcount bumps. Only a
17//! destination that actually reads the message pays to split per write.
18
19use crate::engine::error::Result;
20use crate::engine::functions::template::{Template, TemplateCompiler};
21use crate::engine::task_context::TaskContext;
22use crate::engine::utils::compute_data_path;
23use serde::{Deserialize, Deserializer};
24use serde_json::Value;
25use std::borrow::Cow;
26use std::marker::PhantomData;
27use std::sync::Arc;
28
29/// What a built-in needs to resolve its parameters from inside an arena scope.
30///
31/// The sync built-ins (`map`, `parse_*`, `publish_*`, `validation`) run against
32/// an `ArenaContext` that earlier tasks
33/// in the same stretch already populated, and hold no
34/// [`TaskContext`]. Bundling the three pieces keeps their
35/// signatures to one extra argument, and keeps every parameter resolving
36/// against the *same* context view the task's own logic sees — including
37/// mutations an earlier mapping in the same task made.
38#[derive(Clone, Copy)]
39pub struct ParamCtx<'a> {
40    engine: &'a datalogic_rs::Engine,
41    context: datavalue::DataValue<'a>,
42    arena: &'a datalogic_rs::bumpalo::Bump,
43}
44
45impl<'a> ParamCtx<'a> {
46    pub(crate) fn new(
47        engine: &'a datalogic_rs::Engine,
48        context: datavalue::DataValue<'a>,
49        arena: &'a datalogic_rs::bumpalo::Bump,
50    ) -> Self {
51        Self {
52            engine,
53            context,
54            arena,
55        }
56    }
57
58    /// Build one from an arena context. `DataValue` and `&Bump` both carry the
59    /// arena's `'a`, not a borrow of `arena_ctx`, so the caller may keep
60    /// mutating the context afterwards.
61    pub(crate) fn from_arena(
62        engine: &'a datalogic_rs::Engine,
63        arena_ctx: &crate::engine::executor::ArenaContext<'a>,
64    ) -> Self {
65        Self::new(engine, arena_ctx.as_data_value(), arena_ctx.arena())
66    }
67
68    pub(crate) fn engine(&self) -> &'a datalogic_rs::Engine {
69        self.engine
70    }
71
72    pub(crate) fn context(&self) -> &datavalue::DataValue<'a> {
73        &self.context
74    }
75
76    pub(crate) fn arena(&self) -> &'a datalogic_rs::bumpalo::Bump {
77        self.arena
78    }
79}
80
81/// A resolved write destination: the dotted path, and its pre-split parts.
82///
83/// Both halves are needed at every write. The parts drive the `*_parts` tree
84/// walkers; the dotted form is what [`Change::path`](crate::Change) records on
85/// the audit trail. They travel together so the two can never disagree about
86/// where a value went.
87pub type ResolvedPath = (Arc<str>, Arc<[Arc<str>]>);
88
89/// Where a resolved path string is rooted.
90///
91/// A marker rather than a runtime field so the rooting is fixed by the type of
92/// the config field that holds it, and cannot be lost when a config is built
93/// directly instead of deserialized.
94pub trait PathRoot: Default + Clone + Copy + std::fmt::Debug {
95    /// Compute the `(dotted, parts)` pair for an already-resolved path string.
96    ///
97    /// Parts keep any `#` prefix: it is the explicit "object key, not array
98    /// index" hint that `set_nested_value` consumes when deciding container
99    /// shape, and `strip_hash_prefix` is applied at lookup time inside the
100    /// `*_parts` helpers.
101    fn compute(path: &str) -> ResolvedPath;
102}
103
104/// Rooted at the whole message context: the authored path names its own root,
105/// as in `data.user.name`, `metadata.progress` or `temp_data.i`.
106#[derive(Debug, Clone, Copy, Default)]
107pub struct ContextRoot;
108
109impl PathRoot for ContextRoot {
110    fn compute(path: &str) -> ResolvedPath {
111        (
112            Arc::from(path),
113            path.split('.').map(Arc::from).collect::<Vec<_>>().into(),
114        )
115    }
116}
117
118/// Rooted inside `data`: the authored `"orders"` means `data.orders`. What
119/// `parse_json`, `parse_xml`, `publish_json` and `publish_xml` call `target`.
120#[derive(Debug, Clone, Copy, Default)]
121pub struct DataRoot;
122
123impl PathRoot for DataRoot {
124    fn compute(path: &str) -> ResolvedPath {
125        // `compute_data_path` is the same helper `precompute_target_path` has
126        // always used, so a constant target resolves byte-identically to the
127        // pre-3.9 static field.
128        compute_data_path(path)
129    }
130}
131
132/// A config field naming a write destination, expressed as JSONLogic.
133///
134/// `R` fixes the rooting: [`ContextRoot`] for a path that names its own root,
135/// [`DataRoot`] for one relative to `data`.
136///
137/// The static spelling is a JSON string and keeps its precomputed fast path:
138///
139/// ```json
140/// { "path": "data.total" }
141/// ```
142///
143/// A dynamic destination is any expression resolving to a path string:
144///
145/// ```json
146/// { "path": {"cat": ["data.accounts.", {"var": "data.account_id"}, ".balance"]} }
147/// ```
148///
149/// The expression resolves to the *name* of a location, never to the value at
150/// one. It is evaluated against the message context like any other parameter.
151#[derive(Debug, Clone)]
152pub struct PathTemplate<R: PathRoot = ContextRoot> {
153    template: Template,
154    /// `Some` when the template folded to a compile-time constant — the
155    /// precomputed pair every resolve hands back as two refcount bumps.
156    precomputed: Option<ResolvedPath>,
157    root: PhantomData<R>,
158}
159
160impl<'de, R: PathRoot> Deserialize<'de> for PathTemplate<R> {
161    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
162        Ok(Self::from_template(Template::deserialize(d)?))
163    }
164}
165
166impl<R: PathRoot> Default for PathTemplate<R> {
167    fn default() -> Self {
168        Self::from_template(Template::from(Value::String(String::new())))
169    }
170}
171
172impl<R: PathRoot> From<Value> for PathTemplate<R> {
173    fn from(raw: Value) -> Self {
174        Self::from_template(Template::from(raw))
175    }
176}
177
178impl<R: PathRoot> From<&str> for PathTemplate<R> {
179    fn from(path: &str) -> Self {
180        Self::from(Value::String(path.to_string()))
181    }
182}
183
184impl<R: PathRoot> PathTemplate<R> {
185    fn from_template(template: Template) -> Self {
186        Self {
187            template,
188            precomputed: None,
189            root: PhantomData,
190        }
191    }
192
193    /// Compile the expression and, when it folded to a constant, precompute its
194    /// `(dotted, parts)` pair. Called once at engine construction by
195    /// `LogicCompiler`.
196    ///
197    /// # Errors
198    ///
199    /// As [`Template::compile`], plus a resolution failure if the constant does
200    /// not coerce to a path string.
201    pub fn compile(&mut self, c: &TemplateCompiler, label: &str) -> Result<()> {
202        self.template.compile(c, label)?;
203        // Reading the constant needs no message, so `constant_string` can do
204        // the work `resolve` would otherwise defer to the first message.
205        self.precomputed = self.template.constant_string().map(|s| R::compute(&s));
206        Ok(())
207    }
208
209    /// The destination for this message as `(dotted path, split parts)`.
210    ///
211    /// Constant destinations return the precomputed pair — two `Arc` clones,
212    /// no splitting, no allocation. Dynamic ones resolve to a plain string and
213    /// split it.
214    ///
215    /// # Errors
216    ///
217    /// [`crate::DataflowError::LogicEvaluation`] if the expression fails to
218    /// evaluate, or if it was never compiled and is not a literal string.
219    pub fn resolve(&self, ctx: &TaskContext<'_>) -> Result<ResolvedPath> {
220        if let Some((dotted, parts)) = &self.precomputed {
221            return Ok((Arc::clone(dotted), Arc::clone(parts)));
222        }
223        // A config built directly rather than through `LogicCompiler` — the
224        // test surface and a few in-tree helpers — never had its literal
225        // compiled. Fall back to reading it straight off the authored JSON:
226        // same semantics, one extra allocation per call, which is the contract
227        // `MapConfig`, `ParseConfig` and `PublishConfig` each had before 3.9.
228        if !self.template.is_compiled() {
229            if let Value::String(s) = self.template.as_json() {
230                return Ok(R::compute(s));
231            }
232        }
233        Ok(R::compute(&self.template.resolve_string(ctx)?))
234    }
235
236    /// As [`Self::resolve`], against a context already resident in `arena`.
237    ///
238    /// What the built-in sync executors call: they run inside an arena scope
239    /// whose context earlier tasks already populated, and hold no
240    /// [`TaskContext`]. A constant destination — the overwhelmingly common
241    /// case — returns before any of that matters.
242    ///
243    /// # Errors
244    ///
245    /// As [`Self::resolve`].
246    pub(crate) fn resolve_in_arena(&self, p: ParamCtx<'_>) -> Result<Cow<'_, ResolvedPath>> {
247        // Borrowed, not cloned. Two `Arc` clones per write is two atomic
248        // increments (and two decrements) on the hottest loop in the engine,
249        // for a pair the caller only reads.
250        if let Some(pair) = &self.precomputed {
251            return Ok(Cow::Borrowed(pair));
252        }
253        if !self.template.is_compiled() {
254            if let Value::String(s) = self.template.as_json() {
255                return Ok(Cow::Owned(R::compute(s)));
256            }
257        }
258        Ok(Cow::Owned(R::compute(
259            &self.template.resolve_str_in_arena(p)?,
260        )))
261    }
262
263    /// The authored JSON, unchanged — for authoring checks and for handlers
264    /// that re-serialize their own config.
265    pub fn as_json(&self) -> &Value {
266        self.template.as_json()
267    }
268
269    /// Whether the destination folded to a compile-time constant, so
270    /// [`Self::resolve`] returns the precomputed pair.
271    pub fn is_constant(&self) -> bool {
272        self.precomputed.is_some()
273    }
274
275    /// The constant destination as a dotted string, when it folded to one.
276    ///
277    /// For diagnostics that want to name the destination without a message in
278    /// hand — error labels, the workflow visualizer, authoring issues. `None`
279    /// for a dynamic destination, which has no single answer.
280    pub fn constant_path(&self) -> Option<&str> {
281        self.precomputed.as_ref().map(|(dotted, _)| &**dotted)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::engine::compiler::datalogic_engine_builder;
289    use crate::engine::message::Message;
290    use crate::engine::utils::compute_path_parts;
291    use serde_json::json;
292
293    fn compiler() -> TemplateCompiler {
294        TemplateCompiler::new(Arc::new(datalogic_engine_builder().build()))
295    }
296
297    fn parts_of(parts: &Arc<[Arc<str>]>) -> Vec<String> {
298        parts.iter().map(|p| p.to_string()).collect()
299    }
300
301    #[test]
302    fn a_static_context_path_is_precomputed_and_split_as_before() {
303        let mut p: PathTemplate<ContextRoot> = PathTemplate::from("data.user.name");
304        p.compile(&compiler(), "lbl").unwrap();
305
306        assert!(p.is_constant(), "a literal path must fold to a constant");
307        assert_eq!(p.constant_path(), Some("data.user.name"));
308
309        let mut m = Message::from_value(&json!({}));
310        let dl = Arc::new(datalogic_engine_builder().build());
311        let ctx = TaskContext::new(&mut m, &dl);
312        let (dotted, parts) = p.resolve(&ctx).unwrap();
313        assert_eq!(&*dotted, "data.user.name");
314        assert_eq!(parts_of(&parts), ["data", "user", "name"]);
315    }
316
317    #[test]
318    fn a_static_data_rooted_target_gains_the_data_prefix() {
319        let mut p: PathTemplate<DataRoot> = PathTemplate::from("orders");
320        p.compile(&compiler(), "lbl").unwrap();
321
322        // Byte-identical to what `precompute_target_path` produced pre-3.9.
323        assert_eq!(p.constant_path(), Some("data.orders"));
324        let (dotted, parts) = compute_data_path("orders");
325        assert_eq!(p.constant_path(), Some(&*dotted));
326        assert_eq!(parts_of(&parts), ["data", "orders"]);
327    }
328
329    #[test]
330    fn a_dynamic_path_resolves_against_the_message() {
331        let dl = Arc::new(datalogic_engine_builder().build());
332        let mut p: PathTemplate<ContextRoot> =
333            PathTemplate::from(json!({"cat": ["data.accounts.", {"var": "data.id"}, ".balance"]}));
334        p.compile(&compiler(), "lbl").unwrap();
335
336        assert!(!p.is_constant());
337        assert_eq!(p.constant_path(), None, "a dynamic path names no one place");
338
339        let mut m = Message::from_value(&json!({}));
340        crate::engine::utils::set_nested_value(
341            &mut m.context,
342            "data.id",
343            datavalue::OwnedDataValue::String("ACC7".to_string()),
344        );
345        let ctx = TaskContext::new(&mut m, &dl);
346        let (dotted, parts) = p.resolve(&ctx).unwrap();
347        assert_eq!(&*dotted, "data.accounts.ACC7.balance");
348        assert_eq!(parts_of(&parts), ["data", "accounts", "ACC7", "balance"]);
349    }
350
351    #[test]
352    fn an_uncompiled_literal_still_resolves_for_directly_built_configs() {
353        // The pre-3.9 fallback: a config constructed by hand, never passed
354        // through `LogicCompiler`, still writes to the right place.
355        let dl = Arc::new(datalogic_engine_builder().build());
356        let p: PathTemplate<ContextRoot> = PathTemplate::from("temp_data.x");
357        let mut m = Message::from_value(&json!({}));
358        let ctx = TaskContext::new(&mut m, &dl);
359
360        let (dotted, parts) = p.resolve(&ctx).unwrap();
361        assert_eq!(&*dotted, "temp_data.x");
362        assert_eq!(parts_of(&parts), ["temp_data", "x"]);
363    }
364
365    #[test]
366    fn a_hash_prefixed_segment_survives_the_split() {
367        // `#` is the "object key, not array index" hint; parts keep it and
368        // `strip_hash_prefix` runs at lookup. Splitting must not eat it.
369        let mut p: PathTemplate<ContextRoot> = PathTemplate::from("data.rows.#20.total");
370        p.compile(&compiler(), "lbl").unwrap();
371
372        let dl = Arc::new(datalogic_engine_builder().build());
373        let mut m = Message::from_value(&json!({}));
374        let ctx = TaskContext::new(&mut m, &dl);
375        let (_, parts) = p.resolve(&ctx).unwrap();
376        assert_eq!(parts_of(&parts), ["data", "rows", "#20", "total"]);
377    }
378
379    #[test]
380    fn compute_path_parts_is_the_shared_split_for_data_rooting() {
381        // Pins that `DataRoot` reuses the existing helper rather than growing
382        // a second splitter that could drift from it.
383        let (_, parts) = DataRoot::compute("a.b");
384        assert_eq!(
385            parts_of(&parts),
386            parts_of(&compute_path_parts("data", "a.b"))
387        );
388    }
389}