diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9bcdf7f..ac2ecc0 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -15,6 +15,7 @@ This document describes the available merge strategies in ObjectMerger. Each sec - [MVEL Strategy](#mvel-strategy) - [List Strategy](#list-strategy) - [Map Strategy](#map-strategy) +- [Nested Strategy](#nested-strategy) --- @@ -612,5 +613,71 @@ itemsDef.setItemMergeDefinition(itemDef); #### Java Code Example ```java MapFieldDefinition> transDef = MapFieldDefinition.>builder() +--- + +### Nested Strategy +**Strategy Name:** `nested` +**Description:** Recursively merges nested POJO objects based on a nested definition. This allows for granular control over sub-field merging. + +**Example Scenario:** Merging an address where the street comes from an API source (json2) and the zip code from a database source (json1). + +#### Input Data +**json1 (db)** +```json +{ + "address": { + "street": "Old St", + "zip": "12345" + } +} +``` +**json2 (api)** +```json +{ + "address": { + "street": "New St", + "zip": "99999" + } +} +``` + +#### Merge Definition +```json +{ + "definitions": { + "address": { + "strategy": "nested", + "nestedDefinition": { + "definitions": { + "street": { + "strategy": "priority", + "priority": {"json2": 1, "json1": 2} + }, + "zip": { + "strategy": "priority", + "priority": {"json1": 1, "json2": 2} + } + } + } + } + } +} +``` + +#### Result +```json +{ + "address": { + "street": "New St", + "zip": "12345" + } +} +``` + +#### Java Code Example +```java +NestedFieldDefinition
addressDef = NestedFieldDefinition.
builder() + .nestedDefinition(nestedMergeDefinition) .build(); ``` + diff --git a/README.md b/README.md index 59b607b..e9be98a 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ This builds all modules. The resulting artifacts are located in `target/` of the | **concatenate**| Joins strings. | `{"strategy": "concatenate"}` | | **mergeList** | Merges lists by ID. Supports Template/Intersection. | `{"strategy": "mergeList", "identifyBy": "id", "keyOriginLabels": ["A"], "requirePresenceInAllKeyOrigins": true}` | | **mergeMap** | Vereinigt Maps (Union oder Template) | `{"strategy": "mergeMap"}` | +| **nested**| Deep merge of POJOs using nested definition. | `{"strategy": "nested", "nestedDefinition": {...}}` | | **mvel** | Execute custom scripts. | `{"strategy": "mvel", "expression": "return 1;"}` | ### 8.2 Map Template Logic @@ -240,6 +241,55 @@ The `conditional` strategy acts as a wrapper that routes to different strategies } ``` +### 8.9 Nested POJO Merging +Allows deep merging of nested POJO objects instead of replacing them wholesale. This enables granular control over nested fields. + +**Configuration:** +- `strategy`: "nested" +- `nestedDefinition`: A full `MergeDefinition` for the nested object. + +**Example:** +```json +{ + "address": { + "strategy": "nested", + "nestedDefinition": { + "definitions": { + "street": { "strategy": "priority", "priority": {"api": 1} }, + "zip": { "strategy": "priority", "priority": {"db": 1} } + } + } + } +} +``` + +### 8.10 Synergy: Conditional + Nested +Combine strategies to validate data before deep merging. + +**Example:** +```json +{ + "address": { + "strategy": "conditional", + "cases": [ + { + "condition": "values['api'].isValid == true", + "useStrategy": { + "strategy": "nested", + "nestedDefinition": { + "templateSourceLabel": "api", + "definitions": { + "street": { "strategy": "priority", "priority": {"api": 1} } + } + } + } + } + ], + "defaultStrategy": { "strategy": "priority", "priority": {"db": 1} } + } +} +``` + ## 9. Glossary | Term | Definition | diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/util/MergeDefinitionConverter.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/util/MergeDefinitionConverter.java index 8e77ab8..90c6b3b 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/util/MergeDefinitionConverter.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/util/MergeDefinitionConverter.java @@ -43,6 +43,13 @@ public class MergeDefinitionConverter { case "mvel": targetClass = de.x132.objectmerger.strategy.mvel.MvelFieldDefinition.class; break; + case "nested": + targetClass = de.x132.objectmerger.strategy.nested.NestedFieldDefinition.class; + break; + case "conditional": + targetClass = + de.x132.objectmerger.strategy.conditional.ConditionalFieldDefinition.class; + break; default: targetClass = de.x132.objectmerger.strategy.standard.StandardFieldDefinition.class; diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedFieldDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedFieldDefinition.java new file mode 100644 index 0000000..07ffec9 --- /dev/null +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedFieldDefinition.java @@ -0,0 +1,19 @@ +package de.x132.objectmerger.strategy.nested; + +import de.x132.objectmerger.MergeDefinition; +import de.x132.objectmerger.strategy.FieldDefinition; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class NestedFieldDefinition extends FieldDefinition { + @Builder.Default private MergeDefinition nestedDefinition = new MergeDefinition(); +} diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedMergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedMergeStrategy.java new file mode 100644 index 0000000..68fd972 --- /dev/null +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedMergeStrategy.java @@ -0,0 +1,94 @@ +package de.x132.objectmerger.strategy.nested; + +import de.x132.objectmerger.LabeledSource; +import de.x132.objectmerger.MergeDefinition; +import de.x132.objectmerger.ObjectMerger; +import de.x132.objectmerger.exception.MergeExecutionException; +import de.x132.objectmerger.strategy.MergeStrategy; +import java.util.List; + +public class NestedMergeStrategy implements MergeStrategy> { + + public static final String NAME = "nested"; + + @Override + public String getName() { + return NAME; + } + + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) NestedFieldDefinition.class; + } + + @Override + @SuppressWarnings("unchecked") + public T merge( + List> sources, NestedFieldDefinition fieldDef, String fieldName) { + if (sources.isEmpty()) { + return fieldDef.getDefaultValue(); + } + + // Extract field values from sources + List> nestedSources = + sources.stream() + .map( + s -> { + Object val = ObjectMerger.getFieldValue(s.getSource(), fieldName); + return new LabeledSource<>(s.getLabel(), val); + }) + .collect(java.util.stream.Collectors.toList()); + + // Determine target class from the first non-null nested source + Object firstNonNull = + nestedSources.stream() + .map(LabeledSource::getSource) + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + if (firstNonNull == null) { + return fieldDef.getDefaultValue(); + } + + Class targetClass = firstNonNull.getClass(); + + // Prepare nested definition + MergeDefinition nestedDef = fieldDef.getNestedDefinition(); + + // If nested definition is empty, we must rely on Template Mode for discovery! + // We can synthesize a Template Source Label if we want to force one source to + // be the template? + // Or we just let ObjectMerger handle it. + // Wait, if 'nestedDef' is empty, ObjectMerger will look for 'definitions'. + // If that is empty, it returns empty object (unless template mode is ON). + + // Feature Idea: + // If nestedDefinition has NO templateSource set, but we have sources here, + // should we imply one? + // Let's explicitly support Template Mode in Recursive Field Def? + // Or just say: If you want auto-discovery, set 'templateSourceLabel' in + // 'nestedDefinition'. + // That's cleaner. + + // HOWEVER: We have sources interacting dynamically here. + // The main ObjectMerger loop doesn't pass 'definitions' down automatically + // unless we do it here. + // But 'nestedDef' IS that definition. + + // Cast sources to LabeledSource + LabeledSource[] castSources = + (LabeledSource[]) + nestedSources.stream() + .map(s -> new LabeledSource<>(s.getLabel(), (T) s.getSource())) + .toArray(LabeledSource[]::new); + + try { + Class typedClass = (Class) targetClass; + return ObjectMerger.merge(typedClass, nestedDef, castSources); + } catch (Exception e) { + throw new MergeExecutionException("Recursive merge failed for field " + fieldName, e); + } + } +} diff --git a/objectmerger/src/main/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy b/objectmerger/src/main/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy index 33a6351..5ed8117 100644 --- a/objectmerger/src/main/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy +++ b/objectmerger/src/main/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy @@ -10,3 +10,4 @@ de.x132.objectmerger.strategy.minimum.MinimumValueStrategy de.x132.objectmerger.strategy.maximum.MaximumValueStrategy de.x132.objectmerger.strategy.mvel.MvelMergeStrategy de.x132.objectmerger.strategy.conditional.ConditionalMergeStrategy +de.x132.objectmerger.strategy.nested.NestedMergeStrategy diff --git a/objectmerger/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java b/objectmerger/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java new file mode 100644 index 0000000..a470ca3 --- /dev/null +++ b/objectmerger/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java @@ -0,0 +1,125 @@ +package de.x132.objectmerger; + +import de.x132.objectmerger.strategy.conditional.ConditionCase; +import de.x132.objectmerger.strategy.conditional.ConditionalFieldDefinition; +import de.x132.objectmerger.strategy.conditional.ConditionalMergeStrategy; +import de.x132.objectmerger.strategy.nested.NestedFieldDefinition; +import de.x132.objectmerger.strategy.nested.NestedMergeStrategy; +import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; +import de.x132.objectmerger.strategy.priority.PriorityMergeStrategy; +import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ConditionalNestedTest { + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Address { + private String street; + private String zip; + private boolean valid; + } + + @Test + void testConditionalIncludesNested() { + // Scenario: + // If source2.valid == true, use Nested merge (Street -> Source2, Zip -> + // Source1) + // Else, use Standard merge (Source 1 entirely, ignoring Source 2 invalid data) + + Address a1 = new Address("Old St", "12345", true); + Address a2 = new Address("New St", "99999", true); // Valid API response + + // 1. Define Nested Strategy (The "Target" behavior) + Map> nestedFields = new HashMap<>(); + PriorityFieldDefinition streetDef = new PriorityFieldDefinition<>(); + streetDef.setStrategy(PriorityMergeStrategy.NAME); + streetDef.setPriority(Map.of("source2", 1, "source1", 2)); + nestedFields.put("street", streetDef); + + // Zip: source1 > source2 + PriorityFieldDefinition zipDef = new PriorityFieldDefinition<>(); + zipDef.setStrategy(PriorityMergeStrategy.NAME); + zipDef.setPriority(Map.of("source1", 1, "source2", 2)); + nestedFields.put("zip", zipDef); + + NestedFieldDefinition
nestedDef = new NestedFieldDefinition<>(); + nestedDef.setStrategy(NestedMergeStrategy.NAME); + nestedDef.setNestedDefinition(new MergeDefinition(nestedFields)); + + // 2. Define Conditional Strategy + ConditionalFieldDefinition
conditionalDef = new ConditionalFieldDefinition<>(); + conditionalDef.setStrategy(ConditionalMergeStrategy.NAME); + + // Case 1: source2 is valid -> Use Nested + ConditionCase
validCase = new ConditionCase<>(); + validCase.setCondition("values['source2'].valid == true"); + validCase.setUseStrategy(nestedDef); + + conditionalDef.setCases(List.of(validCase)); + + // Default: Standard (implicit? or explicit StandardFieldDefinition) + // Default behavior of Conditional is Standard if no default set? + // No, strategy logic: "if defaultStrategy != null... else return null". + // We should set a default strategy to be safe, e.g. Priority(source1) or + // Standard. + StandardFieldDefinition
fallback = new StandardFieldDefinition<>(); + // Standard usually picks first non-null. Order is source1, source2. So Source1. + conditionalDef.setDefaultStrategy(fallback); + + MergeDefinition rootDef = new MergeDefinition(); + rootDef.setDefinitions(Map.of("self", conditionalDef)); // Wait, we are merging generic object? + + // Testing wrapping object: + // Let's merge Address directly? PojoMerger merges Fields of targetClass. + // So we need a container class. + + Wrapper w1 = new Wrapper(a1); + Wrapper w2 = new Wrapper(a2); + + MergeDefinition wrapperDef = new MergeDefinition(); + wrapperDef.setDefinitions(Map.of("address", conditionalDef)); + + // Test 1: Valid API + Wrapper result1 = + ObjectMerger.merge( + Wrapper.class, + wrapperDef, + new LabeledSource<>("source1", w1), + new LabeledSource<>("source2", w2)); + + // Expect: Nested merge (Street=New, Zip=12345) + Assertions.assertEquals("New St", result1.getAddress().getStreet()); + Assertions.assertEquals("12345", result1.getAddress().getZip()); + + // Test 2: Invalid API + a2.setValid(false); + // a2.setStreet("Bad St"); + + Wrapper result2 = + ObjectMerger.merge( + Wrapper.class, + wrapperDef, + new LabeledSource<>("source1", w1), + new LabeledSource<>("source2", w2)); + + // Expect: Fallback to Standard (Source 1) + Assertions.assertEquals("Old St", result2.getAddress().getStreet()); + Assertions.assertEquals("12345", result2.getAddress().getZip()); + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Wrapper { + private Address address; + } +} diff --git a/objectmerger/src/test/java/de/x132/objectmerger/NestedMergeTest.java b/objectmerger/src/test/java/de/x132/objectmerger/NestedMergeTest.java new file mode 100644 index 0000000..6757f38 --- /dev/null +++ b/objectmerger/src/test/java/de/x132/objectmerger/NestedMergeTest.java @@ -0,0 +1,141 @@ +package de.x132.objectmerger; + +import de.x132.objectmerger.strategy.nested.NestedFieldDefinition; +import de.x132.objectmerger.strategy.nested.NestedMergeStrategy; +import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; +import de.x132.objectmerger.strategy.priority.PriorityMergeStrategy; +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class NestedMergeTest { + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Person { + private String name; + private Address address; + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Address { + private String street; + private String zip; + private String city; + } + + @Test + void testNestedMergeWithSelectiveFields() { + // Sources + Address a1 = new Address("Old St", "12345", "HomeCity"); + Person p1 = new Person("Max", a1); + + Address a2 = new Address("New St", "99999", "ApiCity"); // API has different data + Person p2 = new Person("Max", a2); + + // Goal: + // Street -> API (Source2) + // Zip -> Local (Source1) + // City -> Local (Source1) - Implicit priority + + // 1. Nested Definition for Address + Map> nestedFields = new HashMap<>(); + + // Street: Priority [source2, source1] + PriorityFieldDefinition streetDef = new PriorityFieldDefinition<>(); + streetDef.setStrategy(PriorityMergeStrategy.NAME); + streetDef.setPriority(Map.of("source2", 1, "source1", 2)); + nestedFields.put("street", streetDef); + + // Zip: Priority [source1, source2] + PriorityFieldDefinition zipDef = new PriorityFieldDefinition<>(); + zipDef.setStrategy(PriorityMergeStrategy.NAME); + zipDef.setPriority(Map.of("source1", 1, "source2", 2)); + nestedFields.put("zip", zipDef); + + MergeDefinition nestedMergeDef = new MergeDefinition(nestedFields); + + // 2. Main Definition for Person + Map> mainFields = new HashMap<>(); + + NestedFieldDefinition
addressDef = new NestedFieldDefinition<>(); + addressDef.setStrategy(NestedMergeStrategy.NAME); + addressDef.setNestedDefinition(nestedMergeDef); + mainFields.put("address", addressDef); + + MergeDefinition mainMergeDef = new MergeDefinition(mainFields); + + // 3. Execution + Person result = + ObjectMerger.merge( + Person.class, + mainMergeDef, + new LabeledSource<>("source1", p1), + new LabeledSource<>("source2", p2)); + + // 4. Verification + Assertions.assertNotNull(result.getAddress()); + Assertions.assertEquals( + "New St", result.getAddress().getStreet(), "Street should come from Source2 (API)"); + Assertions.assertEquals( + "12345", result.getAddress().getZip(), "Zip should come from Source1 (Local)"); + + Assertions.assertNull( + result.getAddress().getCity(), + "City should be null because it was not in nested definition"); + } + + @Test + void testNestedMergeWithTemplateModeSynergy() { + // Same as above, but using Template Mode for the nested definition to include + // ALL fields automatically + + Address a1 = new Address("Old St", "12345", "HomeCity"); + Person p1 = new Person("Max", a1); + + Address a2 = new Address("New St", "99999", "ApiCity"); + Person p2 = new Person("Max", a2); + + // Nested Definition: Use Template Mode "source1" to pick up all fields (street, + // zip, city) + MergeDefinition nestedMergeDef = new MergeDefinition(); + nestedMergeDef.setTemplateSourceLabel("source1"); + + // Override Street to prioritize API + Map> explicitNested = new HashMap<>(); + PriorityFieldDefinition streetDef = new PriorityFieldDefinition<>(); + streetDef.setStrategy(PriorityMergeStrategy.NAME); + streetDef.setPriority(Map.of("source2", 1, "source1", 2)); + explicitNested.put("street", streetDef); + nestedMergeDef.setDefinitions(explicitNested); + + // Main Definition + Map> mainFields = new HashMap<>(); + NestedFieldDefinition
addressDef = new NestedFieldDefinition<>(); + addressDef.setStrategy(NestedMergeStrategy.NAME); + addressDef.setNestedDefinition(nestedMergeDef); + mainFields.put("address", addressDef); + + MergeDefinition mainMergeDef = new MergeDefinition(mainFields); + + Person result = + ObjectMerger.merge( + Person.class, + mainMergeDef, + new LabeledSource<>("source1", p1), + new LabeledSource<>("source2", p2)); + + Assertions.assertEquals("New St", result.getAddress().getStreet()); + Assertions.assertEquals("12345", result.getAddress().getZip()); + + Assertions.assertEquals( + "HomeCity", result.getAddress().getCity(), "City should be included via Template Mode"); + } +}