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
14//! - Audit-trail change tracking
15
16use crate::engine::error::{DataflowError, Result};
17use crate::engine::executor::{ArenaContext, with_arena};
18use crate::engine::functions::path_template::{ContextRoot, ParamCtx, PathTemplate};
19use crate::engine::message::{Change, Message};
20use crate::engine::task_outcome::TaskOutcome;
21use crate::engine::utils::{get_nested_value_parts, set_nested_value_parts};
22use datalogic_rs::{Engine, Logic};
23use datavalue::OwnedDataValue;
24use log::{debug, error};
25use serde::Deserialize;
26use serde_json::Value;
27use std::sync::Arc;
28
29/// Configuration for the map function containing a list of mappings.
30#[derive(Debug, Clone, Deserialize)]
31pub struct MapConfig {
32    /// List of mappings to execute in order.
33    pub mappings: Vec<MapMapping>,
34}
35
36/// A single mapping that transforms and assigns data.
37#[derive(Debug, Clone, Deserialize, Default)]
38pub struct MapMapping {
39    /// Target path where the result will be stored (e.g., `"data.user.name"`).
40    /// Supports dot notation for nested paths and `#` prefix for numeric field
41    /// names.
42    ///
43    /// JSONLogic, so a mapping can compute where it writes:
44    /// `{"cat": ["data.accounts.", {"var": "data.id"}, ".balance"]}`. The
45    /// static spelling is a literal string, which folds at compile time and
46    /// keeps the precomputed split this hot loop has always used — a dynamic
47    /// path is the only one that pays to split per write.
48    pub path: PathTemplate<ContextRoot>,
49
50    /// JSONLogic expression (kept as `serde_json::Value` since this is the
51    /// shape the compiler accepts; not runtime data).
52    pub logic: Value,
53
54    /// Engine-internal: pre-compiled JSONLogic, populated by `LogicCompiler`.
55    /// `None` is logged as an error during execute (the compiler should always
56    /// populate it). Not part of the stable API.
57    #[doc(hidden)]
58    #[serde(skip)]
59    pub compiled_logic: Option<Arc<Logic>>,
60}
61
62impl MapMapping {
63    /// How to name this mapping's destination in a log line or error, without a
64    /// message in hand.
65    ///
66    /// A constant path is its dotted form. A computed one has no single answer
67    /// before evaluation, so it is named by the expression that produces it —
68    /// which is what an author would search their workflow for.
69    pub(crate) fn describe_path(&self) -> String {
70        self.path
71            .constant_path()
72            .map(str::to_string)
73            .unwrap_or_else(|| self.path.as_json().to_string())
74    }
75}
76
77impl MapConfig {
78    /// Parses a `MapConfig` from a JSON value.
79    pub fn from_json(input: &Value) -> Result<Self> {
80        let mappings = input.get("mappings").ok_or_else(|| {
81            DataflowError::Validation("Missing 'mappings' array in input".to_string())
82        })?;
83
84        let mappings_arr = mappings
85            .as_array()
86            .ok_or_else(|| DataflowError::Validation("'mappings' must be an array".to_string()))?;
87
88        let mut parsed_mappings = Vec::new();
89
90        for mapping in mappings_arr {
91            let path = mapping
92                .get("path")
93                .ok_or_else(|| DataflowError::Validation("Missing 'path' in mapping".to_string()))?
94                .clone();
95
96            let logic = mapping
97                .get("logic")
98                .ok_or_else(|| DataflowError::Validation("Missing 'logic' in mapping".to_string()))?
99                .clone();
100
101            parsed_mappings.push(MapMapping {
102                path: PathTemplate::from(path),
103                logic,
104                compiled_logic: None,
105            });
106        }
107
108        Ok(Self {
109            mappings: parsed_mappings,
110        })
111    }
112
113    /// Executes all map transformations using pre-compiled logic.
114    ///
115    /// # Arguments
116    /// * `message` - The message to transform (modified in place)
117    /// * `engine` - Datalogic v5 engine for evaluation
118    pub fn execute(
119        &self,
120        message: &mut Message,
121        engine: &Arc<Engine>,
122    ) -> Result<(TaskOutcome, Vec<Change>)> {
123        // Default path: open the arena, build a fresh ArenaContext from the
124        // current `message.context`, run mappings. Used when no outer
125        // workflow-level arena session is available.
126        with_arena(|arena| {
127            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
128            self.execute_in_arena(message, &mut arena_ctx, engine, None)
129        })
130    }
131
132    /// Mappings-loop run against an externally-provided `ArenaContext`.
133    /// Used by the workflow-level sync-stretch executor so the
134    /// `OwnedDataValue → arena` conversion done by an earlier task in the
135    /// same workflow stretch is reused.
136    ///
137    /// `mapping_snapshots` (when `Some`) collects a `serde_json::Value` snapshot
138    /// of `message.context` *before* each mapping runs — the trace
139    /// surface uses this for per-mapping debugging. `None` skips the snapshot
140    /// work entirely (the production path).
141    /// The `'arena` lifetime ties `&self` to the arena context: the eval
142    /// result borrows both the compiled logic and the arena
143    /// (`Engine::evaluate` unifies them), and the write-through splice needs
144    /// that result at exactly the cache's lifetime.
145    pub(crate) fn execute_in_arena<'arena>(
146        &'arena self,
147        message: &mut Message,
148        arena_ctx: &mut ArenaContext<'arena>,
149        engine: &Arc<Engine>,
150        mut mapping_snapshots: Option<&mut Vec<Value>>,
151    ) -> Result<(TaskOutcome, Vec<Change>)> {
152        // Audit-on runs push one Change per non-null mapping — size for the
153        // common all-mappings-write case up front.
154        let mut changes = if message.capture_changes {
155            Vec::with_capacity(self.mappings.len())
156        } else {
157            Vec::new()
158        };
159        let mut errors_encountered = false;
160
161        debug!("Map: Executing {} mappings", self.mappings.len());
162
163        let arena = arena_ctx.arena();
164        for mapping in &self.mappings {
165            debug!("Processing mapping to path: {}", mapping.describe_path());
166
167            // Trace mode: snapshot the context as a serde_json::Value *before*
168            // applying this mapping. Bridge cost is acceptable on the debug
169            // surface; production callers pass `None` and skip it entirely.
170            if let Some(buf) = mapping_snapshots.as_deref_mut() {
171                buf.push(Value::from(&message.context));
172            }
173
174            // Pre-compiled `Arc<Logic>` lives on the mapping; the workflow
175            // compiler always populates it. `None` only happens for mappings
176            // constructed directly without compilation (test surface) —
177            // logged and skipped here.
178            let compiled_logic = match &mapping.compiled_logic {
179                Some(logic) => logic,
180                None => {
181                    error!(
182                        "Map: Logic not compiled for mapping to {}",
183                        mapping.describe_path()
184                    );
185                    errors_encountered = true;
186                    continue;
187                }
188            };
189
190            let ctx_av = arena_ctx.as_data_value();
191            let result_av = match engine.evaluate(compiled_logic, ctx_av, arena) {
192                Ok(av) => av,
193                Err(e) => {
194                    error!(
195                        "Map: Error evaluating logic for path {}: {:?}",
196                        mapping.describe_path(),
197                        e
198                    );
199                    errors_encountered = true;
200                    continue;
201                }
202            };
203
204            let transformed_value = result_av.to_owned();
205            debug!(
206                "Map: Evaluated logic for path {} resulted in: {:?}",
207                mapping.describe_path(),
208                transformed_value
209            );
210
211            if matches!(transformed_value, OwnedDataValue::Null) {
212                debug!(
213                    "Map: Skipping mapping for path {} as result is null",
214                    mapping.describe_path()
215                );
216                continue;
217            }
218
219            // Where this mapping writes. A constant path — the static
220            // spelling, and the overwhelmingly common case — hands back the
221            // pair precomputed at engine construction as two refcount bumps;
222            // only a computed path splits here. Resolved *after* the logic so
223            // a dynamic destination sees the same context the value did.
224            let resolved = match mapping
225                .path
226                .resolve_in_arena(ParamCtx::new(engine, ctx_av, arena))
227            {
228                Ok(pair) => pair,
229                Err(e) => {
230                    error!(
231                        "Map: Error resolving destination path {}: {:?}",
232                        mapping.describe_path(),
233                        e
234                    );
235                    errors_encountered = true;
236                    continue;
237                }
238            };
239            let (path_arc, parts) = (&resolved.0, &*resolved.1);
240
241            if message.capture_changes {
242                // Audit-on: capture old/new values directly into the `Change`.
243                // `Change` owns `OwnedDataValue`s (not `Arc<…>`) — one fewer
244                // heap allocation per recorded mutation.
245                let old_value = get_nested_value_parts(&message.context, parts)
246                    .cloned()
247                    .unwrap_or(OwnedDataValue::Null);
248                let new_value = transformed_value.clone();
249
250                changes.push(Change {
251                    path: Arc::clone(path_arc),
252                    old_value,
253                    new_value,
254                });
255            }
256            // Write-through: the owned context write is the source of truth;
257            // `result_av` (already arena-resident) is spliced into the cache
258            // directly, avoiding the owned→arena re-walk of the whole target
259            // subtree that made k same-subtree mappings O(k²).
260            arena_ctx.apply_mutation_parts_write_through(
261                &mut message.context,
262                parts,
263                *result_av,
264                |ctx| {
265                    apply_mapping_parts(ctx, parts, path_arc, transformed_value);
266                },
267            );
268            debug!("Successfully mapped to path: {path_arc}");
269        }
270
271        let outcome = if errors_encountered {
272            TaskOutcome::Status(500)
273        } else {
274            TaskOutcome::Success
275        };
276        Ok((outcome, changes))
277    }
278}
279
280/// Pre-split variant of `apply_mapping`. Consumes `parts` for the
281/// `set_nested_value` walk; `full_path` is only needed for the root-merge
282/// detection (which checks the exact, un-split string).
283fn apply_mapping_parts(
284    context: &mut OwnedDataValue,
285    parts: &[Arc<str>],
286    full_path: &str,
287    new_value: OwnedDataValue,
288) {
289    if parts.len() == 1 && matches!(full_path, "data" | "metadata" | "temp_data") {
290        merge_root_field(context, full_path, new_value);
291    } else {
292        set_nested_value_parts(context, parts, new_value);
293    }
294}
295
296/// Merge `new_value` into the existing root-field slot named `path` on the
297/// context object. If both sides are objects, merge keys (new wins for
298/// collisions). Otherwise, overwrite.
299fn merge_root_field(context: &mut OwnedDataValue, path: &str, new_value: OwnedDataValue) {
300    let OwnedDataValue::Object(ctx_pairs) = context else {
301        // The canonical context is always an Object; if somehow not, replace.
302        *context = wrap_root(path, new_value);
303        return;
304    };
305
306    let slot_idx = ctx_pairs.iter().position(|(k, _)| k == path);
307    match slot_idx {
308        Some(idx) => {
309            let slot = &mut ctx_pairs[idx].1;
310            match (slot, new_value) {
311                (OwnedDataValue::Object(existing), OwnedDataValue::Object(new_pairs)) => {
312                    for (k, v) in new_pairs {
313                        if let Some(s) = existing.iter_mut().find(|(ek, _)| ek == &k) {
314                            s.1 = v;
315                        } else {
316                            existing.push((k, v));
317                        }
318                    }
319                }
320                (slot, new) => *slot = new,
321            }
322        }
323        None => {
324            ctx_pairs.push((path.to_string(), new_value));
325        }
326    }
327}
328
329/// Fallback wrap when the top-level context isn't an Object (shouldn't happen
330/// in normal flow but kept for defence in depth).
331fn wrap_root(path: &str, value: OwnedDataValue) -> OwnedDataValue {
332    OwnedDataValue::Object(vec![(path.to_string(), value)])
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::engine::message::Message;
339    use crate::engine::utils::set_nested_value;
340    use serde_json::json;
341
342    fn dv(v: serde_json::Value) -> OwnedDataValue {
343        OwnedDataValue::from(&v)
344    }
345
346    fn fresh_message(initial: serde_json::Value) -> Message {
347        // Build a message whose context's `data` field starts as `initial`.
348        Message::builder().data(dv(initial)).build()
349    }
350
351    #[test]
352    fn test_map_config_from_json() {
353        let input = json!({
354            "mappings": [
355                { "path": "data.field1", "logic": {"var": "data.source"} },
356                { "path": "data.field2", "logic": "static_value" }
357            ]
358        });
359
360        let config = MapConfig::from_json(&input).unwrap();
361        assert_eq!(config.mappings.len(), 2);
362        assert_eq!(config.mappings[0].path.as_json(), &json!("data.field1"));
363        assert_eq!(config.mappings[1].path.as_json(), &json!("data.field2"));
364    }
365
366    #[test]
367    fn test_map_config_missing_mappings() {
368        assert!(MapConfig::from_json(&json!({})).is_err());
369    }
370
371    #[test]
372    fn test_map_config_invalid_mappings() {
373        assert!(MapConfig::from_json(&json!({"mappings": "not_an_array"})).is_err());
374    }
375
376    #[test]
377    fn test_map_config_missing_path() {
378        let input = json!({"mappings": [{"logic": {"var": "data.source"}}]});
379        assert!(MapConfig::from_json(&input).is_err());
380    }
381
382    #[test]
383    fn test_map_config_missing_logic() {
384        let input = json!({"mappings": [{"path": "data.field1"}]});
385        assert!(MapConfig::from_json(&input).is_err());
386    }
387
388    /// Helper that compiles each mapping's `logic` and stamps the resulting
389    /// `Arc<Logic>` into the `compiled_logic` slot — mirroring what
390    /// `LogicCompiler` does at engine construction.
391    fn compile_mappings(engine: &Arc<Engine>, config: &mut MapConfig) {
392        for mapping in &mut config.mappings {
393            mapping.compiled_logic = Some(engine.compile_arc(&mapping.logic).unwrap());
394        }
395    }
396
397    #[test]
398    fn test_map_metadata_assignment() {
399        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
400
401        let mut message = fresh_message(json!({
402            "SwiftMT": { "message_type": "103" }
403        }));
404
405        let mut config = MapConfig {
406            mappings: vec![MapMapping {
407                path: PathTemplate::from("metadata.SwiftMT.message_type"),
408                logic: json!({"var": "data.SwiftMT.message_type"}),
409                ..Default::default()
410            }],
411        };
412        compile_mappings(&engine, &mut config);
413
414        let result = config.execute(&mut message, &engine);
415        assert!(result.is_ok());
416
417        let (outcome, changes) = result.unwrap();
418        assert_eq!(outcome, TaskOutcome::Success);
419        assert_eq!(changes.len(), 1);
420
421        assert_eq!(
422            message.context["metadata"]
423                .get("SwiftMT")
424                .and_then(|v| v.get("message_type")),
425            Some(&dv(json!("103")))
426        );
427    }
428
429    #[test]
430    fn test_map_null_values_skip_assignment() {
431        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
432
433        let mut message = fresh_message(json!({ "existing_field": "should_remain" }));
434        set_nested_value(
435            &mut message.context,
436            "metadata",
437            dv(json!({"existing_meta": "should_remain"})),
438        );
439
440        let mut config = MapConfig {
441            mappings: vec![
442                MapMapping {
443                    path: PathTemplate::from("data.new_field"),
444                    logic: json!({"var": "data.non_existent_field"}),
445                    ..Default::default()
446                },
447                MapMapping {
448                    path: PathTemplate::from("metadata.new_meta"),
449                    logic: json!({"var": "data.another_non_existent"}),
450                    ..Default::default()
451                },
452                MapMapping {
453                    path: PathTemplate::from("data.actual_field"),
454                    logic: json!("actual_value"),
455                    ..Default::default()
456                },
457            ],
458        };
459        compile_mappings(&engine, &mut config);
460
461        let result = config.execute(&mut message, &engine);
462        assert!(result.is_ok());
463
464        let (outcome, changes) = result.unwrap();
465        assert_eq!(outcome, TaskOutcome::Success);
466        assert_eq!(changes.len(), 1);
467        assert_eq!(changes[0].path.as_ref(), "data.actual_field");
468
469        assert_eq!(message.context["data"].get("new_field"), None);
470        assert_eq!(message.context["metadata"].get("new_meta"), None);
471
472        assert_eq!(
473            message.context["data"].get("existing_field"),
474            Some(&dv(json!("should_remain")))
475        );
476        assert_eq!(
477            message.context["metadata"].get("existing_meta"),
478            Some(&dv(json!("should_remain")))
479        );
480
481        assert_eq!(
482            message.context["data"].get("actual_field"),
483            Some(&dv(json!("actual_value")))
484        );
485    }
486
487    #[test]
488    fn test_map_execute_with_trace_captures_context_snapshots() {
489        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
490
491        let mut message = fresh_message(json!({ "first": "Alice", "last": "Smith" }));
492
493        let mut config = MapConfig {
494            mappings: vec![
495                MapMapping {
496                    path: PathTemplate::from("data.full_name"),
497                    logic: json!({"cat": [{"var": "data.first"}, " ", {"var": "data.last"}]}),
498                    ..Default::default()
499                },
500                MapMapping {
501                    path: PathTemplate::from("data.greeting"),
502                    logic: json!({"cat": ["Hello, ", {"var": "data.full_name"}]}),
503                    ..Default::default()
504                },
505            ],
506        };
507        compile_mappings(&engine, &mut config);
508
509        let mut context_snapshots: Vec<Value> = Vec::new();
510        let result = with_arena(|arena| {
511            let mut arena_ctx = ArenaContext::from_owned(&message.context, arena);
512            config.execute_in_arena(
513                &mut message,
514                &mut arena_ctx,
515                &engine,
516                Some(&mut context_snapshots),
517            )
518        });
519        assert!(result.is_ok());
520
521        let (outcome, changes) = result.unwrap();
522        assert_eq!(outcome, TaskOutcome::Success);
523        assert_eq!(changes.len(), 2);
524        assert_eq!(context_snapshots.len(), 2);
525
526        // Snapshots are `serde_json::Value` for the trace surface.
527        assert!(context_snapshots[0]["data"].get("full_name").is_none());
528        assert_eq!(
529            context_snapshots[1]["data"].get("full_name"),
530            Some(&json!("Alice Smith"))
531        );
532    }
533
534    #[test]
535    fn test_map_multiple_fields_including_metadata() {
536        let engine = Arc::new(crate::engine::compiler::datalogic_engine_builder().build());
537
538        let mut message = fresh_message(json!({
539            "ISO20022_MX": {
540                "document": {
541                    "TxInf": {
542                        "OrgnlGrpInf": { "OrgnlMsgNmId": "pacs.008.001.08" }
543                    }
544                }
545            },
546            "SwiftMT": { "message_type": "103" }
547        }));
548
549        let mut config = MapConfig {
550            mappings: vec![
551                MapMapping {
552                    path: PathTemplate::from("data.SwiftMT.message_type"),
553                    logic: json!("103"),
554                    ..Default::default()
555                },
556                MapMapping {
557                    path: PathTemplate::from("metadata.SwiftMT.message_type"),
558                    logic: json!({"var": "data.SwiftMT.message_type"}),
559                    ..Default::default()
560                },
561                MapMapping {
562                    path: PathTemplate::from("temp_data.original_msg_type"),
563                    logic: json!({"var": "data.ISO20022_MX.document.TxInf.OrgnlGrpInf.OrgnlMsgNmId"}),
564                    ..Default::default()
565                },
566            ],
567        };
568        compile_mappings(&engine, &mut config);
569
570        let result = config.execute(&mut message, &engine);
571        assert!(result.is_ok());
572
573        let (outcome, changes) = result.unwrap();
574        assert_eq!(outcome, TaskOutcome::Success);
575        assert_eq!(changes.len(), 3);
576
577        assert_eq!(
578            message.context["data"]
579                .get("SwiftMT")
580                .and_then(|v| v.get("message_type")),
581            Some(&dv(json!("103")))
582        );
583        assert_eq!(
584            message.context["metadata"]
585                .get("SwiftMT")
586                .and_then(|v| v.get("message_type")),
587            Some(&dv(json!("103")))
588        );
589        assert_eq!(
590            message.context["temp_data"].get("original_msg_type"),
591            Some(&dv(json!("pacs.008.001.08")))
592        );
593    }
594}