Skip to main content

dataflow_rs/engine/functions/
map.rs

1//! # Map Function Module
2//!
3//! Data transformation via JSONLogic expressions. Each mapping evaluates a
4//! compiled JSONLogic rule against the message's context (`OwnedDataValue`)
5//! and assigns the result to a path. The result type is `OwnedDataValue` —
6//! no `serde_json::Value` intermediate.
7//!
8//! ## Features
9//!
10//! - JSONLogic-driven transformations
11//! - Dot-path target paths with auto-creation
12//! - Root-field merge semantics for `data` / `metadata` / `temp_data`
13//! - Null results skip assignment by default ("set or keep");
14//!   `on_null: "unset"` removes the path instead
15//! - `unset: true` removes a path outright
16//! - `mode: "append"` / `"extend"` add to an array in place
17//! - Audit-trail change tracking
18
19use crate::engine::error::{DataflowError, Result};
20use crate::engine::executor::{ArenaContext, with_arena};
21use crate::engine::functions::path_template::{ContextRoot, ParamCtx, PathTemplate, ResolvedPath};
22use crate::engine::message::{Change, Message};
23use crate::engine::task_outcome::TaskOutcome;
24use crate::engine::utils::{
25    describe_kind, get_nested_value_parts, get_nested_value_parts_mut, set_nested_value_parts,
26    strip_hash_prefix,
27};
28use datalogic_rs::{Engine, Logic};
29use datavalue::{DataValue, OwnedDataValue};
30use log::{debug, error};
31use serde::de::Error as _;
32use serde::{Deserialize, Deserializer};
33use serde_json::Value;
34use std::sync::Arc;
35
36/// Configuration for the map function containing a list of mappings.
37#[derive(Debug, Clone, Deserialize)]
38pub struct MapConfig {
39    /// List of mappings to execute in order.
40    pub mappings: Vec<MapMapping>,
41}
42
43/// A single mapping that transforms and assigns data — or, with
44/// [`unset`](Self::unset), removes it.
45///
46/// Three rules hold between the keys, and a definition breaking one fails to
47/// parse (so [`Workflow::validate_authored`](crate::Workflow::validate_authored)
48/// reports it as [`IssueCode::InvalidMapping`](crate::IssueCode::InvalidMapping)
49/// and `Engine::build` refuses it):
50///
51/// - a mapping has `logic` or `"unset": true`, never both;
52/// - `on_null` and `mode` need `logic`, since they say what a *result* does;
53/// - a removal, an append or an extend may not name a context root (`data`,
54///   `metadata`, `temp_data`). A literal path is checked here; a computed one
55///   that resolves to a root fails that mapping at run time instead.
56#[derive(Debug, Clone, Default)]
57pub struct MapMapping {
58    /// Target path where the result will be stored (e.g., `"data.user.name"`).
59    /// Supports dot notation for nested paths and `#` prefix for numeric field
60    /// names.
61    ///
62    /// JSONLogic, so a mapping can compute where it writes:
63    /// `{"cat": ["data.accounts.", {"var": "data.id"}, ".balance"]}`. The
64    /// static spelling is a literal string, which folds at compile time and
65    /// keeps the precomputed split this hot loop has always used — a dynamic
66    /// path is the only one that pays to split per write.
67    pub path: PathTemplate<ContextRoot>,
68
69    /// JSONLogic expression (kept as `serde_json::Value` since this is the
70    /// shape the compiler accepts; not runtime data).
71    ///
72    /// `Value::Null` on an [`unset`](Self::unset) mapping, which has none.
73    pub logic: Value,
74
75    /// Remove the key at `path` instead of writing to it. Takes no `logic`.
76    ///
77    /// A no-op when the key is already absent. A removal is recorded on the
78    /// audit trail as a [`Change`] with [`removed`](Change::removed) set.
79    ///
80    /// This exists because `null` cannot mean "clear": a null result is
81    /// skipped (see [`OnNull::Skip`]), which is what lets
82    /// `{"if": [cond, value, null]}` mean "set or keep".
83    pub unset: bool,
84
85    /// What a `null` result from `logic` does. Defaults to
86    /// [`OnNull::Skip`], the historical behaviour.
87    pub on_null: OnNull,
88
89    /// How a non-null result is written. Defaults to [`MapMode::Set`], the
90    /// historical behaviour; [`MapMode::Append`] and [`MapMode::Extend`] add to
91    /// the array at `path` in place.
92    pub mode: MapMode,
93
94    /// Engine-internal: pre-compiled JSONLogic, populated by `LogicCompiler`.
95    /// `None` is logged as an error during execute (the compiler should always
96    /// populate it, except on an `unset` mapping, which has no logic and never
97    /// reads this). Not part of the stable API.
98    #[doc(hidden)]
99    pub compiled_logic: Option<Arc<Logic>>,
100}
101
102/// What a [`MapMapping`] does when its `logic` evaluates to `null`.
103///
104/// `#[non_exhaustive]`: a later minor may add a spelling — writing an explicit
105/// `null`, say, which no mapping can do today.
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
107#[serde(rename_all = "lowercase")]
108#[non_exhaustive]
109pub enum OnNull {
110    /// Leave the path as it was. A `var` that misses and an `if` with no
111    /// matching branch both produce `null`, so skipping is what keeps a
112    /// missing source from wiping its target — and what makes
113    /// `{"if": [cond, value, null]}` mean "set or keep".
114    #[default]
115    Skip,
116    /// Remove the key at `path`, exactly as [`MapMapping::unset`] would:
117    /// `{"if": [cond, value, null]}` then means "set or clear".
118    Unset,
119}
120
121/// How a [`MapMapping`] writes a non-null result to its `path`.
122///
123/// `#[non_exhaustive]`: a later minor may add a spelling.
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
125#[serde(rename_all = "lowercase")]
126#[non_exhaustive]
127pub enum MapMode {
128    /// Replace the value at `path` (merging, when `path` is a context root).
129    #[default]
130    Set,
131    /// Push the result onto the array at `path`, in place.
132    ///
133    /// A missing or `null` target becomes a one-element array; any other
134    /// non-array target fails the mapping rather than being wrapped. Only the
135    /// result is converted and copied, so appending costs the size of the
136    /// entry, not the size of the array: the `merge` idiom it replaces rebuilt
137    /// the whole array on every append, which made a log accumulated over a
138    /// loop quadratic.
139    Append,
140    /// As [`Self::Append`], pushing each element of an array result. A
141    /// non-array result fails the mapping.
142    Extend,
143}
144
145/// A mapping as authored, before the rules between its keys are applied.
146///
147/// Shared by [`MapMapping`]'s `Deserialize` and the authoring walk, so the
148/// rules have one definition and `validate_authored` cannot disagree with the
149/// parser about them.
150#[derive(Deserialize)]
151pub(crate) struct AuthoredMapping {
152    path: PathTemplate<ContextRoot>,
153    /// `Some(Value::Null)` for an explicit `"logic": null`; `None` only when
154    /// the key is absent. A plain `Option<Value>` would fold the two together.
155    #[serde(default, deserialize_with = "present")]
156    logic: Option<Value>,
157    #[serde(default)]
158    unset: bool,
159    /// `Option` so `on_null` without `logic` is caught even when it spells the
160    /// default.
161    #[serde(default)]
162    on_null: Option<OnNull>,
163    /// `Option` for the same reason as `on_null`.
164    #[serde(default)]
165    mode: Option<MapMode>,
166}
167
168/// Deserialize a field that is present, keeping an explicit `null` as
169/// `Some(Value::Null)`.
170fn present<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<Value>, D::Error> {
171    Value::deserialize(d).map(Some)
172}
173
174/// A rule between a mapping's keys that an authored mapping breaks.
175pub(crate) struct MappingProblem {
176    /// The mapping key the problem is reported against.
177    pub(crate) field: &'static str,
178    pub(crate) message: &'static str,
179}
180
181impl AuthoredMapping {
182    /// The first rule this mapping breaks, if any.
183    pub(crate) fn problem(&self) -> Option<MappingProblem> {
184        let problem = |field, message| Some(MappingProblem { field, message });
185        match (&self.logic, self.unset) {
186            (None, false) => {
187                return problem(
188                    "logic",
189                    "a mapping needs `logic`, or `\"unset\": true` to remove the path",
190                );
191            }
192            (Some(_), true) => {
193                return problem(
194                    "unset",
195                    "`unset` removes the path and takes no `logic` — to remove it only \
196                     when the result is null, keep `logic` and use `\"on_null\": \"unset\"`",
197                );
198            }
199            _ => {}
200        }
201        if self.logic.is_none() && self.on_null.is_some() {
202            return problem(
203                "on_null",
204                "`on_null` says what a null `logic` result does, and this mapping has no `logic`",
205            );
206        }
207        if self.logic.is_none() && self.mode.is_some() {
208            return problem(
209                "mode",
210                "`mode` says how a `logic` result is written, and this mapping has no `logic`",
211            );
212        }
213        let literal_root = self
214            .path
215            .as_json()
216            .as_str()
217            .is_some_and(|path| is_context_root(&path.split('.').collect::<Vec<_>>()));
218        if literal_root && (self.unset || self.on_null == Some(OnNull::Unset)) {
219            return problem(
220                "path",
221                "a context root (`data`, `metadata`, `temp_data`) cannot be removed — \
222                 remove the keys under it instead",
223            );
224        }
225        if literal_root && matches!(self.mode, Some(MapMode::Append | MapMode::Extend)) {
226            return problem(
227                "path",
228                "a context root (`data`, `metadata`, `temp_data`) is an object and cannot be \
229                 appended to — name an array under it instead",
230            );
231        }
232        None
233    }
234
235    fn into_mapping(self) -> MapMapping {
236        MapMapping {
237            path: self.path,
238            logic: self.logic.unwrap_or(Value::Null),
239            unset: self.unset,
240            on_null: self.on_null.unwrap_or_default(),
241            mode: self.mode.unwrap_or_default(),
242            compiled_logic: None,
243        }
244    }
245}
246
247impl<'de> Deserialize<'de> for MapMapping {
248    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
249        let authored = AuthoredMapping::deserialize(d)?;
250        if let Some(problem) = authored.problem() {
251            return Err(D::Error::custom(format_args!(
252                "mapping `{}`: {}",
253                problem.field, problem.message
254            )));
255        }
256        Ok(authored.into_mapping())
257    }
258}
259
260/// Whether `parts` names a context root — the one kind of path a mapping may
261/// write (it merges) but never remove.
262fn is_context_root<P: AsRef<str>>(parts: &[P]) -> bool {
263    matches!(parts, [only] if matches!(strip_hash_prefix(only.as_ref()), "data" | "metadata" | "temp_data"))
264}
265
266impl MapMapping {
267    /// How to name this mapping's destination in a log line or error, without a
268    /// message in hand.
269    ///
270    /// A constant path is its dotted form. A computed one has no single answer
271    /// before evaluation, so it is named by the expression that produces it —
272    /// which is what an author would search their workflow for.
273    pub(crate) fn describe_path(&self) -> String {
274        self.path
275            .constant_path()
276            .map(str::to_string)
277            .unwrap_or_else(|| self.path.as_json().to_string())
278    }
279}
280
281impl MapConfig {
282    /// Parses a `MapConfig` from a JSON value.
283    pub fn from_json(input: &Value) -> Result<Self> {
284        let mappings = input.get("mappings").ok_or_else(|| {
285            DataflowError::Validation("Missing 'mappings' array in input".to_string())
286        })?;
287
288        let mappings_arr = mappings
289            .as_array()
290            .ok_or_else(|| DataflowError::Validation("'mappings' must be an array".to_string()))?;
291
292        let mut parsed_mappings = Vec::new();
293
294        for mapping in mappings_arr {
295            if mapping.get("path").is_none() {
296                return Err(DataflowError::Validation(
297                    "Missing 'path' in mapping".to_string(),
298                ));
299            }
300            // The same parse the workflow deserializer runs, so the rules
301            // between `logic`, `unset` and `on_null` hold here too.
302            let parsed = MapMapping::deserialize(mapping)
303                .map_err(|e| DataflowError::Validation(e.to_string()))?;
304            parsed_mappings.push(parsed);
305        }
306
307        Ok(Self {
308            mappings: parsed_mappings,
309        })
310    }
311
312    /// Executes all map transformations using pre-compiled logic.
313    ///
314    /// # Arguments
315    /// * `message` - The message to transform (modified in place)
316    /// * `engine` - Datalogic v5 engine for evaluation
317    pub fn execute(
318        &self,
319        message: &mut Message,
320        engine: &Arc<Engine>,
321    ) -> Result<(TaskOutcome, Vec<Change>)> {
322        // Default path: open the arena, build a fresh ArenaContext from the
323        // current `message.context`, run mappings. Used when no outer
324        // workflow-level arena session is available.
325        with_arena(|arena| {
326            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
327            self.execute_in_arena(message, &mut arena_ctx, engine, None)
328        })
329    }
330
331    /// Mappings-loop run against an externally-provided `ArenaContext`.
332    /// Used by the workflow-level sync-stretch executor so the
333    /// `OwnedDataValue → arena` conversion done by an earlier task in the
334    /// same workflow stretch is reused.
335    ///
336    /// `mapping_snapshots` (when `Some`) collects a `serde_json::Value` snapshot
337    /// of `message.context` *before* each mapping runs — the trace
338    /// surface uses this for per-mapping debugging. `None` skips the snapshot
339    /// work entirely (the production path).
340    /// The `'arena` lifetime ties `&self` to the arena context: the eval
341    /// result borrows both the compiled logic and the arena
342    /// (`Engine::evaluate` unifies them), and the write-through splice needs
343    /// that result at exactly the cache's lifetime.
344    pub(crate) fn execute_in_arena<'arena>(
345        &'arena self,
346        message: &mut Message,
347        arena_ctx: &mut ArenaContext<'arena>,
348        engine: &Arc<Engine>,
349        mut mapping_snapshots: Option<&mut Vec<Value>>,
350    ) -> Result<(TaskOutcome, Vec<Change>)> {
351        // Audit-on runs push one Change per mapping that writes or removes —
352        // size for the common all-mappings-write case up front.
353        let mut changes = if message.capture_changes {
354            Vec::with_capacity(self.mappings.len())
355        } else {
356            Vec::new()
357        };
358        let mut errors_encountered = false;
359
360        debug!("Map: Executing {} mappings", self.mappings.len());
361
362        let arena = arena_ctx.arena();
363        for mapping in &self.mappings {
364            debug!("Processing mapping to path: {}", mapping.describe_path());
365
366            // Trace mode: snapshot the context as a serde_json::Value *before*
367            // applying this mapping. Bridge cost is acceptable on the debug
368            // surface; production callers pass `None` and skip it entirely.
369            if let Some(buf) = mapping_snapshots.as_deref_mut() {
370                buf.push(Value::from(&message.context));
371            }
372
373            if mapping.unset {
374                // Nothing to evaluate: resolve the destination against the
375                // current context and take the key out.
376                let ctx_av = arena_ctx.as_data_value();
377                let Some(resolved) = resolve_destination(mapping, engine, ctx_av, arena) else {
378                    errors_encountered = true;
379                    continue;
380                };
381                if !remove_at(message, arena_ctx, &resolved, &mut changes) {
382                    errors_encountered = true;
383                }
384                continue;
385            }
386
387            // Pre-compiled `Arc<Logic>` lives on the mapping; the workflow
388            // compiler always populates it. `None` only happens for mappings
389            // constructed directly without compilation (test surface) —
390            // logged and skipped here.
391            let compiled_logic = match &mapping.compiled_logic {
392                Some(logic) => logic,
393                None => {
394                    error!(
395                        "Map: Logic not compiled for mapping to {}",
396                        mapping.describe_path()
397                    );
398                    errors_encountered = true;
399                    continue;
400                }
401            };
402
403            let ctx_av = arena_ctx.as_data_value();
404            let result_av = match engine.evaluate(compiled_logic, ctx_av, arena) {
405                Ok(av) => av,
406                Err(e) => {
407                    error!(
408                        "Map: Error evaluating logic for path {}: {:?}",
409                        mapping.describe_path(),
410                        e
411                    );
412                    errors_encountered = true;
413                    continue;
414                }
415            };
416
417            let transformed_value = result_av.to_owned();
418            debug!(
419                "Map: Evaluated logic for path {} resulted in: {:?}",
420                mapping.describe_path(),
421                transformed_value
422            );
423
424            if matches!(transformed_value, OwnedDataValue::Null) {
425                match mapping.on_null {
426                    OnNull::Skip => {
427                        debug!(
428                            "Map: Skipping mapping for path {} as result is null",
429                            mapping.describe_path()
430                        );
431                    }
432                    OnNull::Unset => {
433                        let Some(resolved) = resolve_destination(mapping, engine, ctx_av, arena)
434                        else {
435                            errors_encountered = true;
436                            continue;
437                        };
438                        if !remove_at(message, arena_ctx, &resolved, &mut changes) {
439                            errors_encountered = true;
440                        }
441                    }
442                }
443                continue;
444            }
445
446            // Where this mapping writes. A constant path — the static
447            // spelling, and the overwhelmingly common case — hands back the
448            // pair precomputed at engine construction as two refcount bumps;
449            // only a computed path splits here. Resolved *after* the logic so
450            // a dynamic destination sees the same context the value did.
451            let Some(resolved) = resolve_destination(mapping, engine, ctx_av, arena) else {
452                errors_encountered = true;
453                continue;
454            };
455            if mapping.mode != MapMode::Set {
456                if !append_at(
457                    message,
458                    arena_ctx,
459                    mapping.mode,
460                    &resolved,
461                    result_av,
462                    transformed_value,
463                    &mut changes,
464                ) {
465                    errors_encountered = true;
466                }
467                continue;
468            }
469            let (path_arc, parts) = (&resolved.0, &*resolved.1);
470
471            if message.capture_changes {
472                // Audit-on: capture old/new values directly into the `Change`.
473                // `Change` owns `OwnedDataValue`s (not `Arc<…>`) — one fewer
474                // heap allocation per recorded mutation.
475                let old_value = get_nested_value_parts(&message.context, parts)
476                    .cloned()
477                    .unwrap_or(OwnedDataValue::Null);
478                let new_value = transformed_value.clone();
479
480                changes.push(Change {
481                    path: Arc::clone(path_arc),
482                    old_value,
483                    new_value,
484                    removed: false,
485                });
486            }
487            // Write-through: the owned context write is the source of truth;
488            // `result_av` (already arena-resident) is spliced into the cache
489            // directly, avoiding the owned→arena re-walk of the whole target
490            // subtree that made k same-subtree mappings O(k²).
491            arena_ctx.apply_mutation_parts_write_through(
492                &mut message.context,
493                parts,
494                *result_av,
495                |ctx| {
496                    apply_mapping_parts(ctx, parts, path_arc, transformed_value);
497                },
498            );
499            debug!("Successfully mapped to path: {path_arc}");
500        }
501
502        let outcome = if errors_encountered {
503            TaskOutcome::Status(500)
504        } else {
505            TaskOutcome::Success
506        };
507        Ok((outcome, changes))
508    }
509}
510
511/// Resolve where `mapping` writes, logging a failure. `None` fails the mapping.
512///
513/// Borrows: a constant path hands back the pair precomputed at engine
514/// construction.
515fn resolve_destination<'a>(
516    mapping: &'a MapMapping,
517    engine: &'a Engine,
518    ctx_av: datavalue::DataValue<'a>,
519    arena: &'a datalogic_rs::bumpalo::Bump,
520) -> Option<std::borrow::Cow<'a, ResolvedPath>> {
521    match mapping
522        .path
523        .resolve_in_arena(ParamCtx::new(engine, ctx_av, arena))
524    {
525        Ok(pair) => Some(pair),
526        Err(e) => {
527            error!(
528                "Map: Error resolving destination path {}: {:?}",
529                mapping.describe_path(),
530                e
531            );
532            None
533        }
534    }
535}
536
537/// Remove the key at `resolved` from the context and the arena cache,
538/// recording the removal when audit capture is on. An absent key is a no-op
539/// and records nothing.
540///
541/// `false` when the path names a context root. A literal root is refused at
542/// parse time, so only a computed path that resolves to one reaches here.
543fn remove_at(
544    message: &mut Message,
545    arena_ctx: &mut ArenaContext<'_>,
546    resolved: &ResolvedPath,
547    changes: &mut Vec<Change>,
548) -> bool {
549    let (path_arc, parts) = (&resolved.0, &*resolved.1);
550    if is_context_root(parts) {
551        error!("Map: Refusing to remove context root {path_arc}");
552        return false;
553    }
554    match arena_ctx.apply_removal_parts(&mut message.context, parts) {
555        Some(old_value) => {
556            if message.capture_changes {
557                changes.push(Change {
558                    path: Arc::clone(path_arc),
559                    old_value,
560                    new_value: OwnedDataValue::Null,
561                    removed: true,
562                });
563            }
564            debug!("Map: Removed {path_arc}");
565        }
566        None => debug!("Map: Nothing to remove at {path_arc}"),
567    }
568    true
569}
570
571/// Add a non-null result to the array at `resolved`, in place: one element
572/// for [`MapMode::Append`], each element of an array result for
573/// [`MapMode::Extend`]. A missing or `null` target becomes a new array.
574///
575/// Only the result is copied. The owned array grows by the result's owned form
576/// (already converted for the null check), and the arena cache by a shallow
577/// copy of the old slice plus the result's arena form, so the cost is the size
578/// of the entry, not of the array. Records one [`Change`] per element, at its
579/// index (`data.log.7`), so a traced run does not copy the array either.
580///
581/// `false`, after logging, when the path names a context root, the target is
582/// some other kind of value, or `extend` got a non-array result: no silent
583/// wrapping.
584fn append_at<'a>(
585    message: &mut Message,
586    arena_ctx: &mut ArenaContext<'a>,
587    mode: MapMode,
588    resolved: &ResolvedPath,
589    result_av: &'a DataValue<'a>,
590    result: OwnedDataValue,
591    changes: &mut Vec<Change>,
592) -> bool {
593    let (path_arc, parts) = (&resolved.0, &*resolved.1);
594    if is_context_root(parts) {
595        error!("Map: Refusing to append to context root {path_arc}");
596        return false;
597    }
598    let (items_av, items): (&'a [DataValue<'a>], Vec<OwnedDataValue>) = match (mode, result) {
599        (MapMode::Extend, OwnedDataValue::Array(items)) => match result_av {
600            DataValue::Array(items_av) => (items_av, items),
601            _ => unreachable!("the owned result was converted from this arena value"),
602        },
603        (MapMode::Extend, other) => {
604            error!(
605                "Map: `extend` needs an array result for {path_arc}, got {}",
606                describe_kind(&other)
607            );
608            return false;
609        }
610        (_, value) => (std::slice::from_ref(result_av), vec![value]),
611    };
612    let start = match get_nested_value_parts(&message.context, parts) {
613        None | Some(OwnedDataValue::Null) => 0,
614        Some(OwnedDataValue::Array(existing)) => existing.len(),
615        Some(other) => {
616            error!(
617                "Map: Cannot append to {path_arc}: it holds {}, not an array",
618                describe_kind(other)
619            );
620            return false;
621        }
622    };
623    if message.capture_changes {
624        for (i, item) in items.iter().enumerate() {
625            changes.push(Change {
626                path: Arc::from(format!("{path_arc}.{}", start + i)),
627                old_value: OwnedDataValue::Null,
628                new_value: item.clone(),
629                removed: false,
630            });
631        }
632    }
633
634    let old_av: Option<&[DataValue<'a>]> = if start == 0 {
635        Some(&[])
636    } else {
637        match arena_ctx.cached_at(parts) {
638            Some(DataValue::Array(old)) if old.len() == start => Some(old),
639            _ => None,
640        }
641    };
642    match old_av {
643        Some(old) => {
644            let arena = arena_ctx.arena();
645            let new_av = DataValue::Array(arena.alloc_slice_fill_with(
646                old.len() + items_av.len(),
647                |i| {
648                    if i < old.len() {
649                        old[i]
650                    } else {
651                        items_av[i - old.len()]
652                    }
653                },
654            ));
655            arena_ctx.apply_mutation_parts_write_through(
656                &mut message.context,
657                parts,
658                new_av,
659                |ctx| append_owned(ctx, parts, items),
660            );
661        }
662        // The cache does not hold the array where the owned context does;
663        // re-convert that subtree rather than guess.
664        None => {
665            append_owned(&mut message.context, parts, items);
666            arena_ctx.refresh_for_path_parts(&message.context, parts);
667        }
668    }
669    debug!("Map: Appended to {path_arc}");
670    true
671}
672
673/// The owned half of [`append_at`]: extend the array at `parts`, or create it
674/// when the target is missing or `null` (the caller has refused every other
675/// kind).
676fn append_owned(context: &mut OwnedDataValue, parts: &[Arc<str>], items: Vec<OwnedDataValue>) {
677    match get_nested_value_parts_mut(context, parts) {
678        Some(OwnedDataValue::Array(existing)) => existing.extend(items),
679        _ => set_nested_value_parts(context, parts, OwnedDataValue::Array(items)),
680    }
681}
682
683/// Pre-split variant of `apply_mapping`. Consumes `parts` for the
684/// `set_nested_value` walk; `full_path` is only needed for the root-merge
685/// detection (which checks the exact, un-split string).
686fn apply_mapping_parts(
687    context: &mut OwnedDataValue,
688    parts: &[Arc<str>],
689    full_path: &str,
690    new_value: OwnedDataValue,
691) {
692    if parts.len() == 1 && matches!(full_path, "data" | "metadata" | "temp_data") {
693        merge_root_field(context, full_path, new_value);
694    } else {
695        set_nested_value_parts(context, parts, new_value);
696    }
697}
698
699/// Merge `new_value` into the existing root-field slot named `path` on the
700/// context object. If both sides are objects, merge keys (new wins for
701/// collisions). Otherwise, overwrite.
702fn merge_root_field(context: &mut OwnedDataValue, path: &str, new_value: OwnedDataValue) {
703    let OwnedDataValue::Object(ctx_pairs) = context else {
704        // The canonical context is always an Object; if somehow not, replace.
705        *context = wrap_root(path, new_value);
706        return;
707    };
708
709    let slot_idx = ctx_pairs.iter().position(|(k, _)| k == path);
710    match slot_idx {
711        Some(idx) => {
712            let slot = &mut ctx_pairs[idx].1;
713            match (slot, new_value) {
714                (OwnedDataValue::Object(existing), OwnedDataValue::Object(new_pairs)) => {
715                    for (k, v) in new_pairs {
716                        if let Some(s) = existing.iter_mut().find(|(ek, _)| ek == &k) {
717                            s.1 = v;
718                        } else {
719                            existing.push((k, v));
720                        }
721                    }
722                }
723                (slot, new) => *slot = new,
724            }
725        }
726        None => {
727            ctx_pairs.push((path.to_string(), new_value));
728        }
729    }
730}
731
732/// Fallback wrap when the top-level context isn't an Object (shouldn't happen
733/// in normal flow but kept for defence in depth).
734fn wrap_root(path: &str, value: OwnedDataValue) -> OwnedDataValue {
735    OwnedDataValue::Object(vec![(path.to_string(), value)])
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use crate::engine::message::Message;
742    use crate::engine::utils::set_nested_value;
743    use serde_json::json;
744
745    fn dv(v: serde_json::Value) -> OwnedDataValue {
746        OwnedDataValue::from(&v)
747    }
748
749    fn fresh_message(initial: serde_json::Value) -> Message {
750        // Build a message whose context's `data` field starts as `initial`.
751        Message::builder().data(dv(initial)).build()
752    }
753
754    #[test]
755    fn test_map_config_from_json() {
756        let input = json!({
757            "mappings": [
758                { "path": "data.field1", "logic": {"var": "data.source"} },
759                { "path": "data.field2", "logic": "static_value" }
760            ]
761        });
762
763        let config = MapConfig::from_json(&input).unwrap();
764        assert_eq!(config.mappings.len(), 2);
765        assert_eq!(config.mappings[0].path.as_json(), &json!("data.field1"));
766        assert_eq!(config.mappings[1].path.as_json(), &json!("data.field2"));
767    }
768
769    #[test]
770    fn test_map_config_missing_mappings() {
771        assert!(MapConfig::from_json(&json!({})).is_err());
772    }
773
774    #[test]
775    fn test_map_config_invalid_mappings() {
776        assert!(MapConfig::from_json(&json!({"mappings": "not_an_array"})).is_err());
777    }
778
779    #[test]
780    fn test_map_config_missing_path() {
781        let input = json!({"mappings": [{"logic": {"var": "data.source"}}]});
782        assert!(MapConfig::from_json(&input).is_err());
783    }
784
785    #[test]
786    fn test_map_config_missing_logic() {
787        let input = json!({"mappings": [{"path": "data.field1"}]});
788        assert!(MapConfig::from_json(&input).is_err());
789    }
790
791    /// Helper that compiles each mapping's `logic` and stamps the resulting
792    /// `Arc<Logic>` into the `compiled_logic` slot — mirroring what
793    /// `LogicCompiler` does at engine construction.
794    fn compile_mappings(engine: &Arc<Engine>, config: &mut MapConfig) {
795        for mapping in &mut config.mappings {
796            mapping.compiled_logic = Some(engine.compile_arc(&mapping.logic).unwrap());
797        }
798    }
799
800    #[test]
801    fn test_map_metadata_assignment() {
802        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
803
804        let mut message = fresh_message(json!({
805            "SwiftMT": { "message_type": "103" }
806        }));
807
808        let mut config = MapConfig {
809            mappings: vec![MapMapping {
810                path: PathTemplate::from("metadata.SwiftMT.message_type"),
811                logic: json!({"var": "data.SwiftMT.message_type"}),
812                ..Default::default()
813            }],
814        };
815        compile_mappings(&engine, &mut config);
816
817        let result = config.execute(&mut message, &engine);
818        assert!(result.is_ok());
819
820        let (outcome, changes) = result.unwrap();
821        assert_eq!(outcome, TaskOutcome::Success);
822        assert_eq!(changes.len(), 1);
823
824        assert_eq!(
825            message.context["metadata"]
826                .get("SwiftMT")
827                .and_then(|v| v.get("message_type")),
828            Some(&dv(json!("103")))
829        );
830    }
831
832    #[test]
833    fn test_map_null_values_skip_assignment() {
834        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
835
836        let mut message = fresh_message(json!({ "existing_field": "should_remain" }));
837        set_nested_value(
838            &mut message.context,
839            "metadata",
840            dv(json!({"existing_meta": "should_remain"})),
841        );
842
843        let mut config = MapConfig {
844            mappings: vec![
845                MapMapping {
846                    path: PathTemplate::from("data.new_field"),
847                    logic: json!({"var": "data.non_existent_field"}),
848                    ..Default::default()
849                },
850                MapMapping {
851                    path: PathTemplate::from("metadata.new_meta"),
852                    logic: json!({"var": "data.another_non_existent"}),
853                    ..Default::default()
854                },
855                MapMapping {
856                    path: PathTemplate::from("data.actual_field"),
857                    logic: json!("actual_value"),
858                    ..Default::default()
859                },
860            ],
861        };
862        compile_mappings(&engine, &mut config);
863
864        let result = config.execute(&mut message, &engine);
865        assert!(result.is_ok());
866
867        let (outcome, changes) = result.unwrap();
868        assert_eq!(outcome, TaskOutcome::Success);
869        assert_eq!(changes.len(), 1);
870        assert_eq!(changes[0].path.as_ref(), "data.actual_field");
871
872        assert_eq!(message.context["data"].get("new_field"), None);
873        assert_eq!(message.context["metadata"].get("new_meta"), None);
874
875        assert_eq!(
876            message.context["data"].get("existing_field"),
877            Some(&dv(json!("should_remain")))
878        );
879        assert_eq!(
880            message.context["metadata"].get("existing_meta"),
881            Some(&dv(json!("should_remain")))
882        );
883
884        assert_eq!(
885            message.context["data"].get("actual_field"),
886            Some(&dv(json!("actual_value")))
887        );
888    }
889
890    #[test]
891    fn test_map_execute_with_trace_captures_context_snapshots() {
892        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
893
894        let mut message = fresh_message(json!({ "first": "Alice", "last": "Smith" }));
895
896        let mut config = MapConfig {
897            mappings: vec![
898                MapMapping {
899                    path: PathTemplate::from("data.full_name"),
900                    logic: json!({"cat": [{"var": "data.first"}, " ", {"var": "data.last"}]}),
901                    ..Default::default()
902                },
903                MapMapping {
904                    path: PathTemplate::from("data.greeting"),
905                    logic: json!({"cat": ["Hello, ", {"var": "data.full_name"}]}),
906                    ..Default::default()
907                },
908            ],
909        };
910        compile_mappings(&engine, &mut config);
911
912        let mut context_snapshots: Vec<Value> = Vec::new();
913        let result = with_arena(|arena| {
914            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
915            config.execute_in_arena(
916                &mut message,
917                &mut arena_ctx,
918                &engine,
919                Some(&mut context_snapshots),
920            )
921        });
922        assert!(result.is_ok());
923
924        let (outcome, changes) = result.unwrap();
925        assert_eq!(outcome, TaskOutcome::Success);
926        assert_eq!(changes.len(), 2);
927        assert_eq!(context_snapshots.len(), 2);
928
929        // Snapshots are `serde_json::Value` for the trace surface.
930        assert!(context_snapshots[0]["data"].get("full_name").is_none());
931        assert_eq!(
932            context_snapshots[1]["data"].get("full_name"),
933            Some(&json!("Alice Smith"))
934        );
935    }
936
937    fn parse(mapping: serde_json::Value) -> std::result::Result<MapMapping, String> {
938        MapMapping::deserialize(&mapping).map_err(|e| e.to_string())
939    }
940
941    #[test]
942    fn the_rules_between_mapping_keys() {
943        let unset = parse(json!({"path": "temp_data.x", "unset": true})).unwrap();
944        assert!(unset.unset);
945        assert_eq!(unset.logic, Value::Null);
946        assert_eq!(unset.on_null, OnNull::Skip);
947
948        let on_null =
949            parse(json!({"path": "temp_data.x", "logic": 1, "on_null": "unset"})).unwrap();
950        assert_eq!(on_null.on_null, OnNull::Unset);
951
952        // Back-compat: an explicit null is still accepted, and still skipped.
953        assert!(parse(json!({"path": "data.x", "logic": null})).is_ok());
954
955        for (bad, names) in [
956            (json!({"path": "data.x"}), "`logic`"),
957            (
958                json!({"path": "data.x", "logic": 1, "unset": true}),
959                "`unset`",
960            ),
961            (
962                json!({"path": "data.x", "logic": null, "unset": true}),
963                "`unset`",
964            ),
965            (
966                json!({"path": "data.x", "unset": true, "on_null": "skip"}),
967                "`on_null`",
968            ),
969            (json!({"path": "data", "unset": true}), "`path`"),
970            (json!({"path": "#temp_data", "unset": true}), "`path`"),
971            (
972                json!({"path": "metadata", "logic": 1, "on_null": "unset"}),
973                "`path`",
974            ),
975            (
976                json!({"path": "data.x", "unset": true, "mode": "append"}),
977                "`mode`",
978            ),
979            (
980                json!({"path": "data", "logic": 1, "mode": "append"}),
981                "`path`",
982            ),
983            (
984                json!({"path": "temp_data", "logic": [1], "mode": "extend"}),
985                "`path`",
986            ),
987        ] {
988            let err = parse(bad.clone()).expect_err(&format!("{bad} should be refused"));
989            assert!(err.contains(&format!("mapping {names}")), "{bad}: {err}");
990        }
991
992        let append = parse(json!({"path": "data.log", "logic": 1, "mode": "append"})).unwrap();
993        assert_eq!(append.mode, MapMode::Append);
994        assert_eq!(
995            parse(json!({"path": "data.x", "logic": 1})).unwrap().mode,
996            MapMode::Set
997        );
998
999        // A root is only refused for a removal or an append; writing one
1000        // still merges.
1001        assert!(parse(json!({"path": "data", "logic": {"a": 1}})).is_ok());
1002        assert!(parse(json!({"path": "data", "logic": {"a": 1}, "mode": "set"})).is_ok());
1003        assert!(parse(json!({"path": "data", "logic": 1, "on_null": "skip"})).is_ok());
1004    }
1005
1006    #[test]
1007    fn from_json_applies_the_same_rules() {
1008        assert!(
1009            MapConfig::from_json(&json!({"mappings": [{"path": "data.x", "unset": true}]}))
1010                .unwrap()
1011                .mappings[0]
1012                .unset
1013        );
1014        assert!(
1015            MapConfig::from_json(
1016                &json!({"mappings": [{"path": "data.x", "logic": 1, "unset": true}]})
1017            )
1018            .is_err()
1019        );
1020    }
1021
1022    /// Every removal shape through the arena path, so the unit-test-only
1023    /// differential check in `apply_removal_parts` compares the cache against
1024    /// a rebuild after each one.
1025    #[test]
1026    fn removals_keep_the_arena_cache_in_step() {
1027        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
1028        let mut message = fresh_message(json!({
1029            "a": {"b": {"c": 1, "d": 2}},
1030            "items": [1, 2, 3],
1031            "top": 1,
1032            "7": "hashed"
1033        }));
1034        set_nested_value(&mut message.context, "extra_root", dv(json!({"k": 1})));
1035
1036        let mut config = MapConfig {
1037            mappings: [
1038                "data.a.b.c",
1039                "data.items.1",
1040                "data.top",
1041                "data.#7",
1042                "extra_root",
1043                "data.not.there",
1044            ]
1045            .into_iter()
1046            .map(|path| MapMapping {
1047                path: PathTemplate::from(path),
1048                unset: true,
1049                ..Default::default()
1050            })
1051            .chain([MapMapping {
1052                path: PathTemplate::from("data.a.b.d"),
1053                logic: json!({"var": "data.nope"}),
1054                on_null: OnNull::Unset,
1055                ..Default::default()
1056            }])
1057            .collect(),
1058        };
1059        compile_mappings(&engine, &mut config);
1060
1061        let (outcome, changes) = config.execute(&mut message, &engine).unwrap();
1062        assert_eq!(outcome, TaskOutcome::Success);
1063        assert_eq!(
1064            Value::from(&message.context["data"]),
1065            json!({"a": {"b": {}}, "items": [1, 3]})
1066        );
1067        assert!(message.context.get("extra_root").is_none());
1068        assert_eq!(changes.len(), 6, "the absent path records nothing");
1069        assert!(changes.iter().all(|c| c.removed));
1070    }
1071
1072    /// Every append shape through one arena session, with a later mapping
1073    /// reading each result, so a stale cache would show. The write-through
1074    /// path runs the unit-test differential check after each write; the
1075    /// fallback path is checked against a rebuild at the end.
1076    #[test]
1077    fn appends_keep_the_arena_cache_in_step() {
1078        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
1079        let mut message = fresh_message(json!({
1080            "nul": null,
1081            "a": {"b": {"list": [1]}},
1082            "nested": [[1]],
1083            "top": 5
1084        }));
1085        let m = |path: &str, logic: Value, mode: MapMode| MapMapping {
1086            path: PathTemplate::from(path),
1087            logic,
1088            mode,
1089            ..Default::default()
1090        };
1091        let mut config = MapConfig {
1092            mappings: vec![
1093                m("data.log", json!({"id": 0}), MapMode::Append),
1094                m("data.log", json!({"id": 1}), MapMode::Append),
1095                m("data.log", json!([2, 3]), MapMode::Extend),
1096                m("data.seen", json!({"var": "data.log"}), MapMode::Set),
1097                // Append pushes an array result as one element.
1098                m("data.log", json!([4]), MapMode::Append),
1099                m("data.nul", json!("x"), MapMode::Append),
1100                m("data.a.b.list", json!(2), MapMode::Append),
1101                m(
1102                    "data.a.b.seen",
1103                    json!({"var": "data.a.b.list"}),
1104                    MapMode::Set,
1105                ),
1106                m("data.nested.0", json!(2), MapMode::Append),
1107                m(
1108                    "data.nested_seen",
1109                    json!({"var": "data.nested"}),
1110                    MapMode::Set,
1111                ),
1112                m("data.empty", json!([]), MapMode::Extend),
1113                m(
1114                    "data.skipped",
1115                    json!({"var": "data.missing"}),
1116                    MapMode::Append,
1117                ),
1118            ],
1119        };
1120        compile_mappings(&engine, &mut config);
1121
1122        with_arena(|arena| {
1123            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
1124            let (outcome, changes) = config
1125                .execute_in_arena(&mut message, &mut arena_ctx, &engine, None)
1126                .unwrap();
1127            assert_eq!(outcome, TaskOutcome::Success);
1128            assert_eq!(
1129                arena_ctx.as_data_value().to_owned(),
1130                ArenaContext::from_owned(&message.context, arena)
1131                    .as_data_value()
1132                    .to_owned(),
1133                "the cache matches a rebuild"
1134            );
1135            let paths: Vec<&str> = changes
1136                .iter()
1137                .filter(|c| c.path.starts_with("data.log"))
1138                .map(|c| c.path.as_ref())
1139                .collect();
1140            assert_eq!(
1141                paths,
1142                [
1143                    "data.log.0",
1144                    "data.log.1",
1145                    "data.log.2",
1146                    "data.log.3",
1147                    "data.log.4"
1148                ]
1149            );
1150            assert!(changes.iter().all(|c| c.old_value == OwnedDataValue::Null));
1151        });
1152
1153        let log = json!([{"id": 0}, {"id": 1}, 2, 3, [4]]);
1154        assert_eq!(
1155            Value::from(&message.context["data"]),
1156            json!({
1157                "nul": ["x"],
1158                "a": {"b": {"list": [1, 2], "seen": [1, 2]}},
1159                "nested": [[1, 2]],
1160                "top": 5,
1161                "log": log,
1162                "seen": [{"id": 0}, {"id": 1}, 2, 3],
1163                "nested_seen": [[1, 2]],
1164                "empty": []
1165            })
1166        );
1167    }
1168
1169    #[test]
1170    fn an_append_to_a_non_array_or_an_extend_of_a_non_array_fails_the_mapping() {
1171        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
1172        for (mapping, data) in [
1173            (
1174                json!({"path": "data.top", "logic": 1, "mode": "append"}),
1175                json!({"top": 5}),
1176            ),
1177            (
1178                json!({"path": "data.obj", "logic": [1], "mode": "extend"}),
1179                json!({"obj": {"k": 1}}),
1180            ),
1181            (
1182                json!({"path": "data.log", "logic": 1, "mode": "extend"}),
1183                json!({"log": []}),
1184            ),
1185            (
1186                json!({"path": {"cat": ["da", "ta"]}, "logic": 1, "mode": "append"}),
1187                json!({}),
1188            ),
1189        ] {
1190            let mut message = fresh_message(data.clone());
1191            let mut config = MapConfig::from_json(&json!({"mappings": [mapping]})).unwrap();
1192            compile_mappings(&engine, &mut config);
1193            for m in &mut config.mappings {
1194                m.path
1195                    .compile(
1196                        &crate::engine::functions::TemplateCompiler::new(engine.clone()),
1197                        "path",
1198                    )
1199                    .unwrap();
1200            }
1201            let (outcome, changes) = config.execute(&mut message, &engine).unwrap();
1202            assert_eq!(outcome, TaskOutcome::Status(500), "{mapping}");
1203            assert!(changes.is_empty(), "{mapping}");
1204            assert_eq!(Value::from(&message.context["data"]), data, "{mapping}");
1205        }
1206    }
1207
1208    #[test]
1209    fn test_map_multiple_fields_including_metadata() {
1210        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
1211
1212        let mut message = fresh_message(json!({
1213            "ISO20022_MX": {
1214                "document": {
1215                    "TxInf": {
1216                        "OrgnlGrpInf": { "OrgnlMsgNmId": "pacs.008.001.08" }
1217                    }
1218                }
1219            },
1220            "SwiftMT": { "message_type": "103" }
1221        }));
1222
1223        let mut config = MapConfig {
1224            mappings: vec![
1225                MapMapping {
1226                    path: PathTemplate::from("data.SwiftMT.message_type"),
1227                    logic: json!("103"),
1228                    ..Default::default()
1229                },
1230                MapMapping {
1231                    path: PathTemplate::from("metadata.SwiftMT.message_type"),
1232                    logic: json!({"var": "data.SwiftMT.message_type"}),
1233                    ..Default::default()
1234                },
1235                MapMapping {
1236                    path: PathTemplate::from("temp_data.original_msg_type"),
1237                    logic: json!({"var": "data.ISO20022_MX.document.TxInf.OrgnlGrpInf.OrgnlMsgNmId"}),
1238                    ..Default::default()
1239                },
1240            ],
1241        };
1242        compile_mappings(&engine, &mut config);
1243
1244        let result = config.execute(&mut message, &engine);
1245        assert!(result.is_ok());
1246
1247        let (outcome, changes) = result.unwrap();
1248        assert_eq!(outcome, TaskOutcome::Success);
1249        assert_eq!(changes.len(), 3);
1250
1251        assert_eq!(
1252            message.context["data"]
1253                .get("SwiftMT")
1254                .and_then(|v| v.get("message_type")),
1255            Some(&dv(json!("103")))
1256        );
1257        assert_eq!(
1258            message.context["metadata"]
1259                .get("SwiftMT")
1260                .and_then(|v| v.get("message_type")),
1261            Some(&dv(json!("103")))
1262        );
1263        assert_eq!(
1264            message.context["temp_data"].get("original_msg_type"),
1265            Some(&dv(json!("pacs.008.001.08")))
1266        );
1267    }
1268}