From 2cd97ee5778555c1897774ef80a776c062b4fdb4 Mon Sep 17 00:00:00 2001
From: Felix Reichenbach <felix.reichenbach@mgm-tp.com>
Date: Wed, 26 Mar 2025 15:49:36 +0100
Subject: [PATCH] OZG-7609 handle target path to list element

---
 .../transformation/AggregationMapping.java    |  13 +-
 .../JSLTransformationService.java             |  48 +++-
 .../AggregationMappingTestFactory.java        |   2 +-
 .../FieldMappingTestFactory.java              |   2 +-
 .../transformation/JSLTServiceITCase.java     |   3 +-
 .../JSLTransformationServiceTest.java         | 269 ++++++++++++++++++
 6 files changed, 316 insertions(+), 21 deletions(-)

diff --git a/src/main/java/de/ozgcloud/aggregation/transformation/AggregationMapping.java b/src/main/java/de/ozgcloud/aggregation/transformation/AggregationMapping.java
index 9e6548d..d98c235 100644
--- a/src/main/java/de/ozgcloud/aggregation/transformation/AggregationMapping.java
+++ b/src/main/java/de/ozgcloud/aggregation/transformation/AggregationMapping.java
@@ -23,31 +23,32 @@
  */
 package de.ozgcloud.aggregation.transformation;
 
-import java.util.ArrayList;
 import java.util.List;
 
+import lombok.Builder;
 import lombok.Getter;
-import lombok.Setter;
+import lombok.Singular;
 import lombok.ToString;
 
 @Getter
-@Setter
+@Builder
 @ToString
 public class AggregationMapping {
 
 	private FormIdentifier formIdentifier;
 
-	private List<FieldMapping> fieldMappings = new ArrayList<>();
+	@Singular
+	private List<FieldMapping> fieldMappings;
 
 	@Getter
-	@Setter
+	@Builder
 	public static class FormIdentifier {
 		private String formEngineName;
 		private String formId;
 	}
 
 	@Getter
-	@Setter
+	@Builder
 	public static class FieldMapping {
 		private String sourcePath;
 		private String targetPath;
diff --git a/src/main/java/de/ozgcloud/aggregation/transformation/JSLTransformationService.java b/src/main/java/de/ozgcloud/aggregation/transformation/JSLTransformationService.java
index f5254de..819e415 100644
--- a/src/main/java/de/ozgcloud/aggregation/transformation/JSLTransformationService.java
+++ b/src/main/java/de/ozgcloud/aggregation/transformation/JSLTransformationService.java
@@ -27,15 +27,20 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import org.springframework.stereotype.Service;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.IntNode;
 import com.fasterxml.jackson.databind.node.TextNode;
 import com.schibsted.spt.data.jslt.Expression;
 import com.schibsted.spt.data.jslt.Function;
 import com.schibsted.spt.data.jslt.filters.DefaultJsonFilter;
+import com.schibsted.spt.data.jslt.impl.AbstractNode;
+import com.schibsted.spt.data.jslt.impl.ArraySlicer;
 import com.schibsted.spt.data.jslt.impl.DotExpression;
 import com.schibsted.spt.data.jslt.impl.ExpressionImpl;
 import com.schibsted.spt.data.jslt.impl.ExpressionNode;
@@ -55,8 +60,9 @@ import lombok.RequiredArgsConstructor;
 @RequiredArgsConstructor
 public class JSLTransformationService implements TransformationService {
 
-	private static final LetExpression[] EMPTY_TEMPLATES = new LetExpression[] {};
-	private static final Map<String, Function> EMPTY_FUNCTIONS = Map.of();
+	private static final Pattern LIST_ELEMENT_PATTERN = Pattern.compile("(.+)\\[(\\d+)\\]");
+	static final LetExpression[] EMPTY_TEMPLATES = new LetExpression[] {};
+	static final Map<String, Function> EMPTY_FUNCTIONS = Map.of();
 
 	private final ObjectMapper objectMapper;
 	private final VorgangMapper vorgangMapper;
@@ -86,29 +92,47 @@ public class JSLTransformationService implements TransformationService {
 		return new ExpressionImpl(EMPTY_TEMPLATES, EMPTY_FUNCTIONS, buildObjectExpression(pairExpressions));
 	}
 
-	private ObjectExpression buildObjectExpression(PairExpression... fields) {
+	Optional<PairExpression> toPairExpression(Map.Entry<String, String> mapping) {
+		return transformToDotExpression(mapping.getValue()).map(path -> buildPairExpression(mapping.getKey(), path));
+	}
+
+	ObjectExpression buildObjectExpression(PairExpression... fields) {
 		return new ObjectExpression(EMPTY_TEMPLATES, fields, null, null, new DefaultJsonFilter());
 	}
 
-	private Optional<PairExpression> toPairExpression(Map.Entry<String, String> mapping) {
-		return transformToDotExpression(mapping.getValue()).map(path -> buildPairExpression(mapping.getKey(), path));
+	PairExpression buildPairExpression(String key, ExpressionNode expression) {
+		return new PairExpression(new LiteralExpression(new TextNode(key), null), expression, null);
 	}
 
-	Optional<DotExpression> transformToDotExpression(String path) {
+	Optional<AbstractNode> transformToDotExpression(String path) {
 		if (path == null) {
 			return Optional.empty();
 		} else {
-			String[] fields = path.split("\\.");
-			DotExpression current = null;
+			var fields = path.split("\\.");
+			AbstractNode current = null;
 			for (var field : fields) {
-				current = new DotExpression(field, current, null);
+				var listElementMatcher = LIST_ELEMENT_PATTERN.matcher(field);
+				if (listElementMatcher.matches()) {
+					current = getNodeForListElement(current, listElementMatcher);
+				} else {
+					current = new DotExpression(field, current, null);
+				}
 			}
-			return Optional.ofNullable(current);
+			return Optional.of(current);
 		}
 	}
 
-	private PairExpression buildPairExpression(String key, ExpressionNode expression) {
-		return new PairExpression(new LiteralExpression(new TextNode(key), null), expression, null);
+	private AbstractNode getNodeForListElement(AbstractNode current, Matcher arrayMatcher) {
+		var index = Integer.parseInt(arrayMatcher.group(2));
+		var name = arrayMatcher.group(1);
+		current = slicer(current, name, index);
+		return current;
+	}
+
+	private ArraySlicer slicer(AbstractNode parent, String field, int index) {
+		var idx = new IntNode(index);
+		var node = new DotExpression(field, parent, null);
+		return new ArraySlicer(new LiteralExpression(idx, null), false, null, node, null);
 	}
 
 }
diff --git a/src/test/java/de/ozgcloud/aggregation/transformation/AggregationMappingTestFactory.java b/src/test/java/de/ozgcloud/aggregation/transformation/AggregationMappingTestFactory.java
index a510e05..4be105f 100644
--- a/src/test/java/de/ozgcloud/aggregation/transformation/AggregationMappingTestFactory.java
+++ b/src/test/java/de/ozgcloud/aggregation/transformation/AggregationMappingTestFactory.java
@@ -39,6 +39,6 @@ public class AggregationMappingTestFactory {
 	public static AggregationMappingBuilder createBuilder() {
 		return AggregationMapping.builder()
 				.formIdentifier(FORM_IDENTIFIER)
-				.mapping(MAPPING);
+				.fieldMapping(MAPPING);
 	}
 }
diff --git a/src/test/java/de/ozgcloud/aggregation/transformation/FieldMappingTestFactory.java b/src/test/java/de/ozgcloud/aggregation/transformation/FieldMappingTestFactory.java
index e0ba4b0..fba8b2f 100644
--- a/src/test/java/de/ozgcloud/aggregation/transformation/FieldMappingTestFactory.java
+++ b/src/test/java/de/ozgcloud/aggregation/transformation/FieldMappingTestFactory.java
@@ -46,7 +46,7 @@ public class FieldMappingTestFactory {
 	}
 
 	public static Map<String, String> createAsMap() {
-		return Map.of(SOURCE_PATH, TARGET_PATH);
+		return Map.of(TARGET_PATH, SOURCE_PATH);
 	}
 
 }
diff --git a/src/test/java/de/ozgcloud/aggregation/transformation/JSLTServiceITCase.java b/src/test/java/de/ozgcloud/aggregation/transformation/JSLTServiceITCase.java
index 8e3c1f1..007bf32 100644
--- a/src/test/java/de/ozgcloud/aggregation/transformation/JSLTServiceITCase.java
+++ b/src/test/java/de/ozgcloud/aggregation/transformation/JSLTServiceITCase.java
@@ -61,7 +61,8 @@ class JSLTServiceITCase {
 						.build());
 		var service = new JSLTransformationService(objectMapper, vorgangMapper);
 
-		var transformation = service.load(null, AggregationMappingTestFactory.createBuilder().clearMappings().mappings(fieldMappings).build());
+		var transformation = service.load(null,
+				AggregationMappingTestFactory.createBuilder().clearFieldMappings().fieldMappings(fieldMappings).build());
 
 		var vorgang = OzgCloudVorgangTestFactory.create();
 
diff --git a/src/test/java/de/ozgcloud/aggregation/transformation/JSLTransformationServiceTest.java b/src/test/java/de/ozgcloud/aggregation/transformation/JSLTransformationServiceTest.java
index 28cc8d0..1fa74fd 100644
--- a/src/test/java/de/ozgcloud/aggregation/transformation/JSLTransformationServiceTest.java
+++ b/src/test/java/de/ozgcloud/aggregation/transformation/JSLTransformationServiceTest.java
@@ -39,12 +39,23 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Spy;
 
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
 import com.schibsted.spt.data.jslt.Expression;
 import com.schibsted.spt.data.jslt.impl.DotExpression;
+import com.schibsted.spt.data.jslt.impl.ExpressionImpl;
+import com.schibsted.spt.data.jslt.impl.ObjectExpression;
+import com.schibsted.spt.data.jslt.impl.PairExpression;
+import com.schibsted.spt.data.jslt.impl.Scope;
 import com.thedeanda.lorem.LoremIpsum;
 
 import de.ozgcloud.aggregation.transformation.AggregationMapping.FieldMapping;
+import de.ozgcloud.apilib.vorgang.OzgCloudEingangHeaderTestFactory;
+import de.ozgcloud.apilib.vorgang.OzgCloudVorgangEingangTestFactory;
+import de.ozgcloud.apilib.vorgang.OzgCloudVorgangHeaderTestFactory;
+import de.ozgcloud.apilib.vorgang.OzgCloudVorgangTestFactory;
 
 class JSLTransformationServiceTest {
 
@@ -170,4 +181,262 @@ class JSLTransformationServiceTest {
 			assertThat(map).containsExactlyInAnyOrderEntriesOf(expectedMap);
 		}
 	}
+
+	@Nested
+	class TestCreateExpression {
+
+		@Test
+		void shouldCallToPairExpression() {
+			createExpression();
+
+			verify(service).toPairExpression(Map.entry(FieldMappingTestFactory.TARGET_PATH, FieldMappingTestFactory.SOURCE_PATH));
+		}
+
+		@Test
+		void shouldMapProperty() {
+
+			var expression = service.createExpression(Map.of("target", "vorgangName"));
+
+			var mapped = expression.apply(getVorgangTree());
+			assertThat(mapped).hasToString("{\"target\":\"%s\"}".formatted(OzgCloudVorgangTestFactory.VORGANG_NAME));
+		}
+
+		private JsonNode getVorgangTree() {
+			var mapper = new ObjectMapper().registerModules(new JavaTimeModule());
+			mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+			mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true);
+			var vorgangTree = mapper.valueToTree(OzgCloudVorgangTestFactory.create());
+			return vorgangTree;
+		}
+
+		@Nested
+		class TestOnPairExpressionPresent {
+
+			@Mock
+			private PairExpression pairExpression;
+			@Mock
+			private ObjectExpression objectExpression;
+
+			@BeforeEach
+			void mock() {
+				doReturn(Optional.of(pairExpression)).when(service).toPairExpression(any());
+				doReturn(objectExpression).when(service).buildObjectExpression(any());
+			}
+
+			@Test
+			void shouldBuildObjectExpression() {
+				createExpression();
+
+				verify(service).buildObjectExpression(new PairExpression[] { pairExpression });
+			}
+
+			@Test
+			void shouldReturnObjectExpression() {
+				var expression = createExpression();
+
+				assertThat(expression).usingRecursiveComparison().isEqualTo(
+						new ExpressionImpl(JSLTransformationService.EMPTY_TEMPLATES, JSLTransformationService.EMPTY_FUNCTIONS, objectExpression));
+			}
+		}
+
+		@Nested
+		class TestOnPairExpressionNotPresent {
+
+			@Mock
+			private ObjectExpression objectExpression;
+
+			@BeforeEach
+			void mock() {
+				doReturn(Optional.empty()).when(service).toPairExpression(any());
+				doReturn(objectExpression).when(service).buildObjectExpression();
+			}
+
+			@Test
+			void shouldBuildObjectExpression() {
+				createExpression();
+
+				verify(service).buildObjectExpression();
+			}
+
+			@Test
+			void shouldReturnObjectExpression() {
+				var expression = createExpression();
+				expression.apply(null);
+				assertThat(expression).usingRecursiveComparison().isEqualTo(
+						new ExpressionImpl(JSLTransformationService.EMPTY_TEMPLATES, JSLTransformationService.EMPTY_FUNCTIONS, objectExpression));
+			}
+		}
+
+		private Expression createExpression() {
+			return service.createExpression(FieldMappingTestFactory.createAsMap());
+		}
+	}
+
+	@Nested
+	class TestToPairExpression {
+
+		@Test
+		void shouldMapProperty() {
+			var expression = service.transformToDotExpression("vorgangName");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+			assertThat(mapped).hasToString("\"%s\"".formatted(OzgCloudVorgangTestFactory.VORGANG_NAME));
+		}
+
+		@Test
+		void shouldMapNestedProperty() {
+
+			var expression = service.transformToDotExpression("header.aktenzeichen");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+			assertThat(mapped).hasToString("\"%s\"".formatted(OzgCloudVorgangHeaderTestFactory.AKTENZEICHEN));
+		}
+
+		@Test
+		void shouldMapListElement() {
+
+			var expression = service.transformToDotExpression("eingangs[0]");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+
+			assertThat(mapped).hasToString(getEingangTree().toString());
+		}
+
+		@Test
+		void shouldMapListElementProperty() {
+
+			var expression = service.transformToDotExpression("eingangs[0].header.formEngineName");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+
+			assertThat(mapped).hasToString("\"%s\"".formatted(OzgCloudEingangHeaderTestFactory.FORM_ENGINE_NAME));
+		}
+
+		private JsonNode getVorgangTree() {
+			var mapper = new ObjectMapper().registerModules(new JavaTimeModule());
+			mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+			mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true);
+			return mapper.valueToTree(OzgCloudVorgangTestFactory.create());
+		}
+
+		private JsonNode getEingangTree() {
+			var mapper = new ObjectMapper().registerModules(new JavaTimeModule());
+			mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+			mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true);
+			return mapper.valueToTree(OzgCloudVorgangEingangTestFactory.create());
+		}
+
+		@Nested
+		class TestOnDotExpressionPresent {
+
+			@Mock
+			private DotExpression dotExpression;
+			@Mock
+			private PairExpression pairExpression;
+
+			@BeforeEach
+			void mock() {
+				doReturn(Optional.of(dotExpression)).when(service).transformToDotExpression(any());
+				doReturn(pairExpression).when(service).buildPairExpression(any(), any());
+			}
+
+			@Test
+			void shouldBuildPairExpression() {
+				toPairExpression();
+
+				verify(service).buildPairExpression(FieldMappingTestFactory.SOURCE_PATH,
+						dotExpression);
+			}
+
+			@Test
+			void shouldReturnPairExpression() {
+				var returnedPairExpression = toPairExpression();
+
+				assertThat(returnedPairExpression).contains(pairExpression);
+			}
+		}
+
+		@Nested
+		class TestOnDotExpressionNotPresent {
+
+			@BeforeEach
+			void mock() {
+				doReturn(Optional.empty()).when(service).transformToDotExpression(any());
+			}
+
+			@Test
+			void shouldReturnEmpty() {
+				var returnedPairExpression = toPairExpression();
+
+				assertThat(returnedPairExpression).isEmpty();
+			}
+		}
+
+		private Optional<PairExpression> toPairExpression() {
+			return service.toPairExpression(Map.entry(FieldMappingTestFactory.SOURCE_PATH,
+					FieldMappingTestFactory.TARGET_PATH));
+		}
+	}
+
+	@Nested
+	class TestTransformToDotExpression {
+
+		@Test
+		void shouldReturnEmptyOnNullPath() {
+			var dotExpression = service.transformToDotExpression(null);
+
+			assertThat(dotExpression).isEmpty();
+		}
+
+		@Test
+		void shouldMapProperty() {
+			var expression = service.transformToDotExpression("vorgangName");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+			assertThat(mapped).hasToString("\"%s\"".formatted(OzgCloudVorgangTestFactory.VORGANG_NAME));
+		}
+
+		@Test
+		void shouldMapNestedProperty() {
+
+			var expression = service.transformToDotExpression("header.aktenzeichen");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+			assertThat(mapped).hasToString("\"%s\"".formatted(OzgCloudVorgangHeaderTestFactory.AKTENZEICHEN));
+		}
+
+		@Test
+		void shouldMapListElement() {
+
+			var expression = service.transformToDotExpression("eingangs[0]");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+
+			assertThat(mapped).hasToString(getEingangTree().toString());
+		}
+
+		@Test
+		void shouldMapListElementProperty() {
+
+			var expression = service.transformToDotExpression("eingangs[0].header.formEngineName");
+
+			var mapped = expression.get().apply(Scope.getRoot(2), getVorgangTree());
+
+			assertThat(mapped).hasToString("\"%s\"".formatted(OzgCloudEingangHeaderTestFactory.FORM_ENGINE_NAME));
+		}
+
+		private JsonNode getVorgangTree() {
+			var mapper = new ObjectMapper().registerModules(new JavaTimeModule());
+			mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+			mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true);
+			return mapper.valueToTree(OzgCloudVorgangTestFactory.create());
+		}
+
+		private JsonNode getEingangTree() {
+			var mapper = new ObjectMapper().registerModules(new JavaTimeModule());
+			mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
+			mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true);
+			return mapper.valueToTree(OzgCloudVorgangEingangTestFactory.create());
+		}
+	}
 }
-- 
GitLab