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//! - Audit-trail change tracking
17
18use crate::engine::error::{DataflowError, Result};
19use crate::engine::executor::{ArenaContext, with_arena};
20use crate::engine::functions::path_template::{ContextRoot, ParamCtx, PathTemplate, ResolvedPath};
21use crate::engine::message::{Change, Message};
22use crate::engine::task_outcome::TaskOutcome;
23use crate::engine::utils::{get_nested_value_parts, set_nested_value_parts, strip_hash_prefix};
24use datalogic_rs::{Engine, Logic};
25use datavalue::OwnedDataValue;
26use log::{debug, error};
27use serde::de::Error as _;
28use serde::{Deserialize, Deserializer};
29use serde_json::Value;
30use std::sync::Arc;
31
32/// Configuration for the map function containing a list of mappings.
33#[derive(Debug, Clone, Deserialize)]
34pub struct MapConfig {
35    /// List of mappings to execute in order.
36    pub mappings: Vec<MapMapping>,
37}
38
39/// A single mapping that transforms and assigns data — or, with
40/// [`unset`](Self::unset), removes it.
41///
42/// Three rules hold between the keys, and a definition breaking one fails to
43/// parse (so [`Workflow::validate_authored`](crate::Workflow::validate_authored)
44/// reports it as [`IssueCode::InvalidMapping`](crate::IssueCode::InvalidMapping)
45/// and `Engine::build` refuses it):
46///
47/// - a mapping has `logic` or `"unset": true`, never both;
48/// - `on_null` needs `logic`, since it says what a null *result* does;
49/// - a removal may not name a context root (`data`, `metadata`, `temp_data`).
50///   A literal path is checked here; a computed one that resolves to a root
51///   fails that mapping at run time instead.
52#[derive(Debug, Clone, Default)]
53pub struct MapMapping {
54    /// Target path where the result will be stored (e.g., `"data.user.name"`).
55    /// Supports dot notation for nested paths and `#` prefix for numeric field
56    /// names.
57    ///
58    /// JSONLogic, so a mapping can compute where it writes:
59    /// `{"cat": ["data.accounts.", {"var": "data.id"}, ".balance"]}`. The
60    /// static spelling is a literal string, which folds at compile time and
61    /// keeps the precomputed split this hot loop has always used — a dynamic
62    /// path is the only one that pays to split per write.
63    pub path: PathTemplate<ContextRoot>,
64
65    /// JSONLogic expression (kept as `serde_json::Value` since this is the
66    /// shape the compiler accepts; not runtime data).
67    ///
68    /// `Value::Null` on an [`unset`](Self::unset) mapping, which has none.
69    pub logic: Value,
70
71    /// Remove the key at `path` instead of writing to it. Takes no `logic`.
72    ///
73    /// A no-op when the key is already absent. A removal is recorded on the
74    /// audit trail as a [`Change`] with [`removed`](Change::removed) set.
75    ///
76    /// This exists because `null` cannot mean "clear": a null result is
77    /// skipped (see [`OnNull::Skip`]), which is what lets
78    /// `{"if": [cond, value, null]}` mean "set or keep".
79    pub unset: bool,
80
81    /// What a `null` result from `logic` does. Defaults to
82    /// [`OnNull::Skip`], the historical behaviour.
83    pub on_null: OnNull,
84
85    /// Engine-internal: pre-compiled JSONLogic, populated by `LogicCompiler`.
86    /// `None` is logged as an error during execute (the compiler should always
87    /// populate it, except on an `unset` mapping, which has no logic and never
88    /// reads this). Not part of the stable API.
89    #[doc(hidden)]
90    pub compiled_logic: Option<Arc<Logic>>,
91}
92
93/// What a [`MapMapping`] does when its `logic` evaluates to `null`.
94///
95/// `#[non_exhaustive]`: a later minor may add a spelling — writing an explicit
96/// `null`, say, which no mapping can do today.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
98#[serde(rename_all = "lowercase")]
99#[non_exhaustive]
100pub enum OnNull {
101    /// Leave the path as it was. A `var` that misses and an `if` with no
102    /// matching branch both produce `null`, so skipping is what keeps a
103    /// missing source from wiping its target — and what makes
104    /// `{"if": [cond, value, null]}` mean "set or keep".
105    #[default]
106    Skip,
107    /// Remove the key at `path`, exactly as [`MapMapping::unset`] would:
108    /// `{"if": [cond, value, null]}` then means "set or clear".
109    Unset,
110}
111
112/// A mapping as authored, before the rules between its keys are applied.
113///
114/// Shared by [`MapMapping`]'s `Deserialize` and the authoring walk, so the
115/// rules have one definition and `validate_authored` cannot disagree with the
116/// parser about them.
117#[derive(Deserialize)]
118pub(crate) struct AuthoredMapping {
119    path: PathTemplate<ContextRoot>,
120    /// `Some(Value::Null)` for an explicit `"logic": null`; `None` only when
121    /// the key is absent. A plain `Option<Value>` would fold the two together.
122    #[serde(default, deserialize_with = "present")]
123    logic: Option<Value>,
124    #[serde(default)]
125    unset: bool,
126    /// `Option` so `on_null` without `logic` is caught even when it spells the
127    /// default.
128    #[serde(default)]
129    on_null: Option<OnNull>,
130}
131
132/// Deserialize a field that is present, keeping an explicit `null` as
133/// `Some(Value::Null)`.
134fn present<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<Value>, D::Error> {
135    Value::deserialize(d).map(Some)
136}
137
138/// A rule between a mapping's keys that an authored mapping breaks.
139pub(crate) struct MappingProblem {
140    /// The mapping key the problem is reported against.
141    pub(crate) field: &'static str,
142    pub(crate) message: &'static str,
143}
144
145impl AuthoredMapping {
146    /// The first rule this mapping breaks, if any.
147    pub(crate) fn problem(&self) -> Option<MappingProblem> {
148        let problem = |field, message| Some(MappingProblem { field, message });
149        match (&self.logic, self.unset) {
150            (None, false) => {
151                return problem(
152                    "logic",
153                    "a mapping needs `logic`, or `\"unset\": true` to remove the path",
154                );
155            }
156            (Some(_), true) => {
157                return problem(
158                    "unset",
159                    "`unset` removes the path and takes no `logic` — to remove it only \
160                     when the result is null, keep `logic` and use `\"on_null\": \"unset\"`",
161                );
162            }
163            _ => {}
164        }
165        if self.logic.is_none() && self.on_null.is_some() {
166            return problem(
167                "on_null",
168                "`on_null` says what a null `logic` result does, and this mapping has no `logic`",
169            );
170        }
171        let removes = self.unset || self.on_null == Some(OnNull::Unset);
172        if removes
173            && let Some(path) = self.path.as_json().as_str()
174            && is_context_root(&path.split('.').collect::<Vec<_>>())
175        {
176            return problem(
177                "path",
178                "a context root (`data`, `metadata`, `temp_data`) cannot be removed — \
179                 remove the keys under it instead",
180            );
181        }
182        None
183    }
184
185    fn into_mapping(self) -> MapMapping {
186        MapMapping {
187            path: self.path,
188            logic: self.logic.unwrap_or(Value::Null),
189            unset: self.unset,
190            on_null: self.on_null.unwrap_or_default(),
191            compiled_logic: None,
192        }
193    }
194}
195
196impl<'de> Deserialize<'de> for MapMapping {
197    fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
198        let authored = AuthoredMapping::deserialize(d)?;
199        if let Some(problem) = authored.problem() {
200            return Err(D::Error::custom(format_args!(
201                "mapping `{}`: {}",
202                problem.field, problem.message
203            )));
204        }
205        Ok(authored.into_mapping())
206    }
207}
208
209/// Whether `parts` names a context root — the one kind of path a mapping may
210/// write (it merges) but never remove.
211fn is_context_root<P: AsRef<str>>(parts: &[P]) -> bool {
212    matches!(parts, [only] if matches!(strip_hash_prefix(only.as_ref()), "data" | "metadata" | "temp_data"))
213}
214
215impl MapMapping {
216    /// How to name this mapping's destination in a log line or error, without a
217    /// message in hand.
218    ///
219    /// A constant path is its dotted form. A computed one has no single answer
220    /// before evaluation, so it is named by the expression that produces it —
221    /// which is what an author would search their workflow for.
222    pub(crate) fn describe_path(&self) -> String {
223        self.path
224            .constant_path()
225            .map(str::to_string)
226            .unwrap_or_else(|| self.path.as_json().to_string())
227    }
228}
229
230impl MapConfig {
231    /// Parses a `MapConfig` from a JSON value.
232    pub fn from_json(input: &Value) -> Result<Self> {
233        let mappings = input.get("mappings").ok_or_else(|| {
234            DataflowError::Validation("Missing 'mappings' array in input".to_string())
235        })?;
236
237        let mappings_arr = mappings
238            .as_array()
239            .ok_or_else(|| DataflowError::Validation("'mappings' must be an array".to_string()))?;
240
241        let mut parsed_mappings = Vec::new();
242
243        for mapping in mappings_arr {
244            if mapping.get("path").is_none() {
245                return Err(DataflowError::Validation(
246                    "Missing 'path' in mapping".to_string(),
247                ));
248            }
249            // The same parse the workflow deserializer runs, so the rules
250            // between `logic`, `unset` and `on_null` hold here too.
251            let parsed = MapMapping::deserialize(mapping)
252                .map_err(|e| DataflowError::Validation(e.to_string()))?;
253            parsed_mappings.push(parsed);
254        }
255
256        Ok(Self {
257            mappings: parsed_mappings,
258        })
259    }
260
261    /// Executes all map transformations using pre-compiled logic.
262    ///
263    /// # Arguments
264    /// * `message` - The message to transform (modified in place)
265    /// * `engine` - Datalogic v5 engine for evaluation
266    pub fn execute(
267        &self,
268        message: &mut Message,
269        engine: &Arc<Engine>,
270    ) -> Result<(TaskOutcome, Vec<Change>)> {
271        // Default path: open the arena, build a fresh ArenaContext from the
272        // current `message.context`, run mappings. Used when no outer
273        // workflow-level arena session is available.
274        with_arena(|arena| {
275            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
276            self.execute_in_arena(message, &mut arena_ctx, engine, None)
277        })
278    }
279
280    /// Mappings-loop run against an externally-provided `ArenaContext`.
281    /// Used by the workflow-level sync-stretch executor so the
282    /// `OwnedDataValue → arena` conversion done by an earlier task in the
283    /// same workflow stretch is reused.
284    ///
285    /// `mapping_snapshots` (when `Some`) collects a `serde_json::Value` snapshot
286    /// of `message.context` *before* each mapping runs — the trace
287    /// surface uses this for per-mapping debugging. `None` skips the snapshot
288    /// work entirely (the production path).
289    /// The `'arena` lifetime ties `&self` to the arena context: the eval
290    /// result borrows both the compiled logic and the arena
291    /// (`Engine::evaluate` unifies them), and the write-through splice needs
292    /// that result at exactly the cache's lifetime.
293    pub(crate) fn execute_in_arena<'arena>(
294        &'arena self,
295        message: &mut Message,
296        arena_ctx: &mut ArenaContext<'arena>,
297        engine: &Arc<Engine>,
298        mut mapping_snapshots: Option<&mut Vec<Value>>,
299    ) -> Result<(TaskOutcome, Vec<Change>)> {
300        // Audit-on runs push one Change per mapping that writes or removes —
301        // size for the common all-mappings-write case up front.
302        let mut changes = if message.capture_changes {
303            Vec::with_capacity(self.mappings.len())
304        } else {
305            Vec::new()
306        };
307        let mut errors_encountered = false;
308
309        debug!("Map: Executing {} mappings", self.mappings.len());
310
311        let arena = arena_ctx.arena();
312        for mapping in &self.mappings {
313            debug!("Processing mapping to path: {}", mapping.describe_path());
314
315            // Trace mode: snapshot the context as a serde_json::Value *before*
316            // applying this mapping. Bridge cost is acceptable on the debug
317            // surface; production callers pass `None` and skip it entirely.
318            if let Some(buf) = mapping_snapshots.as_deref_mut() {
319                buf.push(Value::from(&message.context));
320            }
321
322            if mapping.unset {
323                // Nothing to evaluate: resolve the destination against the
324                // current context and take the key out.
325                let ctx_av = arena_ctx.as_data_value();
326                let Some(resolved) = resolve_destination(mapping, engine, ctx_av, arena) else {
327                    errors_encountered = true;
328                    continue;
329                };
330                if !remove_at(message, arena_ctx, &resolved, &mut changes) {
331                    errors_encountered = true;
332                }
333                continue;
334            }
335
336            // Pre-compiled `Arc<Logic>` lives on the mapping; the workflow
337            // compiler always populates it. `None` only happens for mappings
338            // constructed directly without compilation (test surface) —
339            // logged and skipped here.
340            let compiled_logic = match &mapping.compiled_logic {
341                Some(logic) => logic,
342                None => {
343                    error!(
344                        "Map: Logic not compiled for mapping to {}",
345                        mapping.describe_path()
346                    );
347                    errors_encountered = true;
348                    continue;
349                }
350            };
351
352            let ctx_av = arena_ctx.as_data_value();
353            let result_av = match engine.evaluate(compiled_logic, ctx_av, arena) {
354                Ok(av) => av,
355                Err(e) => {
356                    error!(
357                        "Map: Error evaluating logic for path {}: {:?}",
358                        mapping.describe_path(),
359                        e
360                    );
361                    errors_encountered = true;
362                    continue;
363                }
364            };
365
366            let transformed_value = result_av.to_owned();
367            debug!(
368                "Map: Evaluated logic for path {} resulted in: {:?}",
369                mapping.describe_path(),
370                transformed_value
371            );
372
373            if matches!(transformed_value, OwnedDataValue::Null) {
374                match mapping.on_null {
375                    OnNull::Skip => {
376                        debug!(
377                            "Map: Skipping mapping for path {} as result is null",
378                            mapping.describe_path()
379                        );
380                    }
381                    OnNull::Unset => {
382                        let Some(resolved) = resolve_destination(mapping, engine, ctx_av, arena)
383                        else {
384                            errors_encountered = true;
385                            continue;
386                        };
387                        if !remove_at(message, arena_ctx, &resolved, &mut changes) {
388                            errors_encountered = true;
389                        }
390                    }
391                }
392                continue;
393            }
394
395            // Where this mapping writes. A constant path — the static
396            // spelling, and the overwhelmingly common case — hands back the
397            // pair precomputed at engine construction as two refcount bumps;
398            // only a computed path splits here. Resolved *after* the logic so
399            // a dynamic destination sees the same context the value did.
400            let Some(resolved) = resolve_destination(mapping, engine, ctx_av, arena) else {
401                errors_encountered = true;
402                continue;
403            };
404            let (path_arc, parts) = (&resolved.0, &*resolved.1);
405
406            if message.capture_changes {
407                // Audit-on: capture old/new values directly into the `Change`.
408                // `Change` owns `OwnedDataValue`s (not `Arc<…>`) — one fewer
409                // heap allocation per recorded mutation.
410                let old_value = get_nested_value_parts(&message.context, parts)
411                    .cloned()
412                    .unwrap_or(OwnedDataValue::Null);
413                let new_value = transformed_value.clone();
414
415                changes.push(Change {
416                    path: Arc::clone(path_arc),
417                    old_value,
418                    new_value,
419                    removed: false,
420                });
421            }
422            // Write-through: the owned context write is the source of truth;
423            // `result_av` (already arena-resident) is spliced into the cache
424            // directly, avoiding the owned→arena re-walk of the whole target
425            // subtree that made k same-subtree mappings O(k²).
426            arena_ctx.apply_mutation_parts_write_through(
427                &mut message.context,
428                parts,
429                *result_av,
430                |ctx| {
431                    apply_mapping_parts(ctx, parts, path_arc, transformed_value);
432                },
433            );
434            debug!("Successfully mapped to path: {path_arc}");
435        }
436
437        let outcome = if errors_encountered {
438            TaskOutcome::Status(500)
439        } else {
440            TaskOutcome::Success
441        };
442        Ok((outcome, changes))
443    }
444}
445
446/// Resolve where `mapping` writes, logging a failure. `None` fails the mapping.
447///
448/// Borrows: a constant path hands back the pair precomputed at engine
449/// construction.
450fn resolve_destination<'a>(
451    mapping: &'a MapMapping,
452    engine: &'a Engine,
453    ctx_av: datavalue::DataValue<'a>,
454    arena: &'a datalogic_rs::bumpalo::Bump,
455) -> Option<std::borrow::Cow<'a, ResolvedPath>> {
456    match mapping
457        .path
458        .resolve_in_arena(ParamCtx::new(engine, ctx_av, arena))
459    {
460        Ok(pair) => Some(pair),
461        Err(e) => {
462            error!(
463                "Map: Error resolving destination path {}: {:?}",
464                mapping.describe_path(),
465                e
466            );
467            None
468        }
469    }
470}
471
472/// Remove the key at `resolved` from the context and the arena cache,
473/// recording the removal when audit capture is on. An absent key is a no-op
474/// and records nothing.
475///
476/// `false` when the path names a context root. A literal root is refused at
477/// parse time, so only a computed path that resolves to one reaches here.
478fn remove_at(
479    message: &mut Message,
480    arena_ctx: &mut ArenaContext<'_>,
481    resolved: &ResolvedPath,
482    changes: &mut Vec<Change>,
483) -> bool {
484    let (path_arc, parts) = (&resolved.0, &*resolved.1);
485    if is_context_root(parts) {
486        error!("Map: Refusing to remove context root {path_arc}");
487        return false;
488    }
489    match arena_ctx.apply_removal_parts(&mut message.context, parts) {
490        Some(old_value) => {
491            if message.capture_changes {
492                changes.push(Change {
493                    path: Arc::clone(path_arc),
494                    old_value,
495                    new_value: OwnedDataValue::Null,
496                    removed: true,
497                });
498            }
499            debug!("Map: Removed {path_arc}");
500        }
501        None => debug!("Map: Nothing to remove at {path_arc}"),
502    }
503    true
504}
505
506/// Pre-split variant of `apply_mapping`. Consumes `parts` for the
507/// `set_nested_value` walk; `full_path` is only needed for the root-merge
508/// detection (which checks the exact, un-split string).
509fn apply_mapping_parts(
510    context: &mut OwnedDataValue,
511    parts: &[Arc<str>],
512    full_path: &str,
513    new_value: OwnedDataValue,
514) {
515    if parts.len() == 1 && matches!(full_path, "data" | "metadata" | "temp_data") {
516        merge_root_field(context, full_path, new_value);
517    } else {
518        set_nested_value_parts(context, parts, new_value);
519    }
520}
521
522/// Merge `new_value` into the existing root-field slot named `path` on the
523/// context object. If both sides are objects, merge keys (new wins for
524/// collisions). Otherwise, overwrite.
525fn merge_root_field(context: &mut OwnedDataValue, path: &str, new_value: OwnedDataValue) {
526    let OwnedDataValue::Object(ctx_pairs) = context else {
527        // The canonical context is always an Object; if somehow not, replace.
528        *context = wrap_root(path, new_value);
529        return;
530    };
531
532    let slot_idx = ctx_pairs.iter().position(|(k, _)| k == path);
533    match slot_idx {
534        Some(idx) => {
535            let slot = &mut ctx_pairs[idx].1;
536            match (slot, new_value) {
537                (OwnedDataValue::Object(existing), OwnedDataValue::Object(new_pairs)) => {
538                    for (k, v) in new_pairs {
539                        if let Some(s) = existing.iter_mut().find(|(ek, _)| ek == &k) {
540                            s.1 = v;
541                        } else {
542                            existing.push((k, v));
543                        }
544                    }
545                }
546                (slot, new) => *slot = new,
547            }
548        }
549        None => {
550            ctx_pairs.push((path.to_string(), new_value));
551        }
552    }
553}
554
555/// Fallback wrap when the top-level context isn't an Object (shouldn't happen
556/// in normal flow but kept for defence in depth).
557fn wrap_root(path: &str, value: OwnedDataValue) -> OwnedDataValue {
558    OwnedDataValue::Object(vec![(path.to_string(), value)])
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::engine::message::Message;
565    use crate::engine::utils::set_nested_value;
566    use serde_json::json;
567
568    fn dv(v: serde_json::Value) -> OwnedDataValue {
569        OwnedDataValue::from(&v)
570    }
571
572    fn fresh_message(initial: serde_json::Value) -> Message {
573        // Build a message whose context's `data` field starts as `initial`.
574        Message::builder().data(dv(initial)).build()
575    }
576
577    #[test]
578    fn test_map_config_from_json() {
579        let input = json!({
580            "mappings": [
581                { "path": "data.field1", "logic": {"var": "data.source"} },
582                { "path": "data.field2", "logic": "static_value" }
583            ]
584        });
585
586        let config = MapConfig::from_json(&input).unwrap();
587        assert_eq!(config.mappings.len(), 2);
588        assert_eq!(config.mappings[0].path.as_json(), &json!("data.field1"));
589        assert_eq!(config.mappings[1].path.as_json(), &json!("data.field2"));
590    }
591
592    #[test]
593    fn test_map_config_missing_mappings() {
594        assert!(MapConfig::from_json(&json!({})).is_err());
595    }
596
597    #[test]
598    fn test_map_config_invalid_mappings() {
599        assert!(MapConfig::from_json(&json!({"mappings": "not_an_array"})).is_err());
600    }
601
602    #[test]
603    fn test_map_config_missing_path() {
604        let input = json!({"mappings": [{"logic": {"var": "data.source"}}]});
605        assert!(MapConfig::from_json(&input).is_err());
606    }
607
608    #[test]
609    fn test_map_config_missing_logic() {
610        let input = json!({"mappings": [{"path": "data.field1"}]});
611        assert!(MapConfig::from_json(&input).is_err());
612    }
613
614    /// Helper that compiles each mapping's `logic` and stamps the resulting
615    /// `Arc<Logic>` into the `compiled_logic` slot — mirroring what
616    /// `LogicCompiler` does at engine construction.
617    fn compile_mappings(engine: &Arc<Engine>, config: &mut MapConfig) {
618        for mapping in &mut config.mappings {
619            mapping.compiled_logic = Some(engine.compile_arc(&mapping.logic).unwrap());
620        }
621    }
622
623    #[test]
624    fn test_map_metadata_assignment() {
625        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
626
627        let mut message = fresh_message(json!({
628            "SwiftMT": { "message_type": "103" }
629        }));
630
631        let mut config = MapConfig {
632            mappings: vec![MapMapping {
633                path: PathTemplate::from("metadata.SwiftMT.message_type"),
634                logic: json!({"var": "data.SwiftMT.message_type"}),
635                ..Default::default()
636            }],
637        };
638        compile_mappings(&engine, &mut config);
639
640        let result = config.execute(&mut message, &engine);
641        assert!(result.is_ok());
642
643        let (outcome, changes) = result.unwrap();
644        assert_eq!(outcome, TaskOutcome::Success);
645        assert_eq!(changes.len(), 1);
646
647        assert_eq!(
648            message.context["metadata"]
649                .get("SwiftMT")
650                .and_then(|v| v.get("message_type")),
651            Some(&dv(json!("103")))
652        );
653    }
654
655    #[test]
656    fn test_map_null_values_skip_assignment() {
657        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
658
659        let mut message = fresh_message(json!({ "existing_field": "should_remain" }));
660        set_nested_value(
661            &mut message.context,
662            "metadata",
663            dv(json!({"existing_meta": "should_remain"})),
664        );
665
666        let mut config = MapConfig {
667            mappings: vec![
668                MapMapping {
669                    path: PathTemplate::from("data.new_field"),
670                    logic: json!({"var": "data.non_existent_field"}),
671                    ..Default::default()
672                },
673                MapMapping {
674                    path: PathTemplate::from("metadata.new_meta"),
675                    logic: json!({"var": "data.another_non_existent"}),
676                    ..Default::default()
677                },
678                MapMapping {
679                    path: PathTemplate::from("data.actual_field"),
680                    logic: json!("actual_value"),
681                    ..Default::default()
682                },
683            ],
684        };
685        compile_mappings(&engine, &mut config);
686
687        let result = config.execute(&mut message, &engine);
688        assert!(result.is_ok());
689
690        let (outcome, changes) = result.unwrap();
691        assert_eq!(outcome, TaskOutcome::Success);
692        assert_eq!(changes.len(), 1);
693        assert_eq!(changes[0].path.as_ref(), "data.actual_field");
694
695        assert_eq!(message.context["data"].get("new_field"), None);
696        assert_eq!(message.context["metadata"].get("new_meta"), None);
697
698        assert_eq!(
699            message.context["data"].get("existing_field"),
700            Some(&dv(json!("should_remain")))
701        );
702        assert_eq!(
703            message.context["metadata"].get("existing_meta"),
704            Some(&dv(json!("should_remain")))
705        );
706
707        assert_eq!(
708            message.context["data"].get("actual_field"),
709            Some(&dv(json!("actual_value")))
710        );
711    }
712
713    #[test]
714    fn test_map_execute_with_trace_captures_context_snapshots() {
715        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
716
717        let mut message = fresh_message(json!({ "first": "Alice", "last": "Smith" }));
718
719        let mut config = MapConfig {
720            mappings: vec![
721                MapMapping {
722                    path: PathTemplate::from("data.full_name"),
723                    logic: json!({"cat": [{"var": "data.first"}, " ", {"var": "data.last"}]}),
724                    ..Default::default()
725                },
726                MapMapping {
727                    path: PathTemplate::from("data.greeting"),
728                    logic: json!({"cat": ["Hello, ", {"var": "data.full_name"}]}),
729                    ..Default::default()
730                },
731            ],
732        };
733        compile_mappings(&engine, &mut config);
734
735        let mut context_snapshots: Vec<Value> = Vec::new();
736        let result = with_arena(|arena| {
737            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
738            config.execute_in_arena(
739                &mut message,
740                &mut arena_ctx,
741                &engine,
742                Some(&mut context_snapshots),
743            )
744        });
745        assert!(result.is_ok());
746
747        let (outcome, changes) = result.unwrap();
748        assert_eq!(outcome, TaskOutcome::Success);
749        assert_eq!(changes.len(), 2);
750        assert_eq!(context_snapshots.len(), 2);
751
752        // Snapshots are `serde_json::Value` for the trace surface.
753        assert!(context_snapshots[0]["data"].get("full_name").is_none());
754        assert_eq!(
755            context_snapshots[1]["data"].get("full_name"),
756            Some(&json!("Alice Smith"))
757        );
758    }
759
760    fn parse(mapping: serde_json::Value) -> std::result::Result<MapMapping, String> {
761        MapMapping::deserialize(&mapping).map_err(|e| e.to_string())
762    }
763
764    #[test]
765    fn the_rules_between_mapping_keys() {
766        let unset = parse(json!({"path": "temp_data.x", "unset": true})).unwrap();
767        assert!(unset.unset);
768        assert_eq!(unset.logic, Value::Null);
769        assert_eq!(unset.on_null, OnNull::Skip);
770
771        let on_null =
772            parse(json!({"path": "temp_data.x", "logic": 1, "on_null": "unset"})).unwrap();
773        assert_eq!(on_null.on_null, OnNull::Unset);
774
775        // Back-compat: an explicit null is still accepted, and still skipped.
776        assert!(parse(json!({"path": "data.x", "logic": null})).is_ok());
777
778        for (bad, names) in [
779            (json!({"path": "data.x"}), "`logic`"),
780            (
781                json!({"path": "data.x", "logic": 1, "unset": true}),
782                "`unset`",
783            ),
784            (
785                json!({"path": "data.x", "logic": null, "unset": true}),
786                "`unset`",
787            ),
788            (
789                json!({"path": "data.x", "unset": true, "on_null": "skip"}),
790                "`on_null`",
791            ),
792            (json!({"path": "data", "unset": true}), "`path`"),
793            (json!({"path": "#temp_data", "unset": true}), "`path`"),
794            (
795                json!({"path": "metadata", "logic": 1, "on_null": "unset"}),
796                "`path`",
797            ),
798        ] {
799            let err = parse(bad.clone()).expect_err(&format!("{bad} should be refused"));
800            assert!(err.contains(&format!("mapping {names}")), "{bad}: {err}");
801        }
802
803        // A root is only refused for a removal; writing one still merges.
804        assert!(parse(json!({"path": "data", "logic": {"a": 1}})).is_ok());
805        assert!(parse(json!({"path": "data", "logic": 1, "on_null": "skip"})).is_ok());
806    }
807
808    #[test]
809    fn from_json_applies_the_same_rules() {
810        assert!(
811            MapConfig::from_json(&json!({"mappings": [{"path": "data.x", "unset": true}]}))
812                .unwrap()
813                .mappings[0]
814                .unset
815        );
816        assert!(
817            MapConfig::from_json(
818                &json!({"mappings": [{"path": "data.x", "logic": 1, "unset": true}]})
819            )
820            .is_err()
821        );
822    }
823
824    /// Every removal shape through the arena path, so the unit-test-only
825    /// differential check in `apply_removal_parts` compares the cache against
826    /// a rebuild after each one.
827    #[test]
828    fn removals_keep_the_arena_cache_in_step() {
829        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
830        let mut message = fresh_message(json!({
831            "a": {"b": {"c": 1, "d": 2}},
832            "items": [1, 2, 3],
833            "top": 1,
834            "7": "hashed"
835        }));
836        set_nested_value(&mut message.context, "extra_root", dv(json!({"k": 1})));
837
838        let mut config = MapConfig {
839            mappings: [
840                "data.a.b.c",
841                "data.items.1",
842                "data.top",
843                "data.#7",
844                "extra_root",
845                "data.not.there",
846            ]
847            .into_iter()
848            .map(|path| MapMapping {
849                path: PathTemplate::from(path),
850                unset: true,
851                ..Default::default()
852            })
853            .chain([MapMapping {
854                path: PathTemplate::from("data.a.b.d"),
855                logic: json!({"var": "data.nope"}),
856                on_null: OnNull::Unset,
857                ..Default::default()
858            }])
859            .collect(),
860        };
861        compile_mappings(&engine, &mut config);
862
863        let (outcome, changes) = config.execute(&mut message, &engine).unwrap();
864        assert_eq!(outcome, TaskOutcome::Success);
865        assert_eq!(
866            Value::from(&message.context["data"]),
867            json!({"a": {"b": {}}, "items": [1, 3]})
868        );
869        assert!(message.context.get("extra_root").is_none());
870        assert_eq!(changes.len(), 6, "the absent path records nothing");
871        assert!(changes.iter().all(|c| c.removed));
872    }
873
874    #[test]
875    fn test_map_multiple_fields_including_metadata() {
876        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
877
878        let mut message = fresh_message(json!({
879            "ISO20022_MX": {
880                "document": {
881                    "TxInf": {
882                        "OrgnlGrpInf": { "OrgnlMsgNmId": "pacs.008.001.08" }
883                    }
884                }
885            },
886            "SwiftMT": { "message_type": "103" }
887        }));
888
889        let mut config = MapConfig {
890            mappings: vec![
891                MapMapping {
892                    path: PathTemplate::from("data.SwiftMT.message_type"),
893                    logic: json!("103"),
894                    ..Default::default()
895                },
896                MapMapping {
897                    path: PathTemplate::from("metadata.SwiftMT.message_type"),
898                    logic: json!({"var": "data.SwiftMT.message_type"}),
899                    ..Default::default()
900                },
901                MapMapping {
902                    path: PathTemplate::from("temp_data.original_msg_type"),
903                    logic: json!({"var": "data.ISO20022_MX.document.TxInf.OrgnlGrpInf.OrgnlMsgNmId"}),
904                    ..Default::default()
905                },
906            ],
907        };
908        compile_mappings(&engine, &mut config);
909
910        let result = config.execute(&mut message, &engine);
911        assert!(result.is_ok());
912
913        let (outcome, changes) = result.unwrap();
914        assert_eq!(outcome, TaskOutcome::Success);
915        assert_eq!(changes.len(), 3);
916
917        assert_eq!(
918            message.context["data"]
919                .get("SwiftMT")
920                .and_then(|v| v.get("message_type")),
921            Some(&dv(json!("103")))
922        );
923        assert_eq!(
924            message.context["metadata"]
925                .get("SwiftMT")
926                .and_then(|v| v.get("message_type")),
927            Some(&dv(json!("103")))
928        );
929        assert_eq!(
930            message.context["temp_data"].get("original_msg_type"),
931            Some(&dv(json!("pacs.008.001.08")))
932        );
933    }
934}