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}