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