diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dc4bc87..f3a297fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Added
+- Issue [#188](https://github.com/42BV/beanmapper/issues/188) Made BeanProperty-annotation repeatable. Added targets-property to BeanProperty-annotation, allowing the user to specify which mappings a BeanProperty should apply to.
+
+## [4.1.6]
+
+### Fixed
+
- Added diagnostics, allowing users to check what mappings and conversion are performed as part of a given mapping.
### NB
diff --git a/pom.xml b/pom.xml
index 4f346234..8cac229e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -56,7 +56,7 @@
2.0.132.17.0
- 5.10.2
+ 5.10.33.13.03.2.5
@@ -84,6 +84,12 @@
${jackson-databind.version}test
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.11.0
+ test
+
diff --git a/src/main/java/io/beanmapper/annotations/BeanProperty.java b/src/main/java/io/beanmapper/annotations/BeanProperty.java
index e27e9051..21d7d710 100644
--- a/src/main/java/io/beanmapper/annotations/BeanProperty.java
+++ b/src/main/java/io/beanmapper/annotations/BeanProperty.java
@@ -1,6 +1,7 @@
package io.beanmapper.annotations;
import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@@ -9,12 +10,34 @@
* Setting a name allows you to map the property to a property on the other side with a different
* name. This annotation can be used on both sides.
*/
+@Repeatable(BeanProperty.BeanProperties.class)
@Target({ ElementType.FIELD, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface BeanProperty {
+ Class> WILDCARD_TYPE = Void.class;
+
+ /**
+ * Making this property available makes application code require guard clauses. As such, best to remove this.
+ */
+ @Deprecated(forRemoval = true)
String name() default "";
String value() default "";
+ /**
+ * Allows the user to specify which mappings this BeanProperty should apply to. When this property contains Void.class, the BeanProperty will apply to any mappings that do not have a BeanProperty specified.
+ *
+ * @return The mappings to which this BeanProperty should apply.
+ */
+ Class>[] targets() default { Void.class };
+
+ /**
+ * Allows the BeanProperty-annotation to be repeated.
+ */
+ @Target({ ElementType.FIELD, ElementType.METHOD })
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface BeanProperties {
+ BeanProperty[] value();
+ }
}
diff --git a/src/main/java/io/beanmapper/config/CoreConfiguration.java b/src/main/java/io/beanmapper/config/CoreConfiguration.java
index de481001..dea3efd6 100644
--- a/src/main/java/io/beanmapper/config/CoreConfiguration.java
+++ b/src/main/java/io/beanmapper/config/CoreConfiguration.java
@@ -14,6 +14,7 @@
import io.beanmapper.core.constructor.DefaultBeanInitializer;
import io.beanmapper.core.converter.BeanConverter;
import io.beanmapper.core.converter.BeanConverterStore;
+import io.beanmapper.core.inspector.BeanPropertySelector;
import io.beanmapper.core.unproxy.BeanUnproxy;
import io.beanmapper.core.unproxy.DefaultBeanUnproxy;
import io.beanmapper.core.unproxy.SkippingBeanUnproxy;
@@ -44,7 +45,7 @@ public class CoreConfiguration implements Configuration {
* Contains a store of matches for source and target class pairs. A pair is created only
* once and reused every time thereafter.
*/
- private final BeanMatchStore beanMatchStore = new BeanMatchStore(collectionHandlerStore, beanUnproxy);
+ private final BeanMatchStore beanMatchStore = new BeanMatchStore(collectionHandlerStore, beanUnproxy, new BeanPropertySelector());
private final BeanConverterStore beanConverterStore = new BeanConverterStore();
diff --git a/src/main/java/io/beanmapper/core/BeanMatchStore.java b/src/main/java/io/beanmapper/core/BeanMatchStore.java
index ce485e47..ecd60d95 100644
--- a/src/main/java/io/beanmapper/core/BeanMatchStore.java
+++ b/src/main/java/io/beanmapper/core/BeanMatchStore.java
@@ -5,12 +5,7 @@
import static io.beanmapper.core.converter.collections.CollectionElementType.derived;
import static io.beanmapper.core.converter.collections.CollectionElementType.set;
-import java.beans.IntrospectionException;
-import java.beans.Introspector;
-import java.lang.reflect.Field;
-import java.lang.reflect.Modifier;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -28,13 +23,12 @@
import io.beanmapper.core.converter.collections.AnnotationClass;
import io.beanmapper.core.converter.collections.BeanCollectionInstructions;
import io.beanmapper.core.converter.collections.CollectionElementType;
+import io.beanmapper.core.inspector.BeanPropertySelector;
import io.beanmapper.core.inspector.PropertyAccessor;
import io.beanmapper.core.inspector.PropertyAccessors;
import io.beanmapper.core.unproxy.BeanUnproxy;
import io.beanmapper.exceptions.BeanMissingPathException;
import io.beanmapper.exceptions.BeanNoSuchPropertyException;
-import io.beanmapper.exceptions.FieldShadowingException;
-import io.beanmapper.utils.BeanMapperTraceLogger;
import io.beanmapper.utils.Trinary;
public class BeanMatchStore {
@@ -45,9 +39,12 @@ public class BeanMatchStore {
private final Map, Map, BeanMatch>> store = new HashMap<>();
- public BeanMatchStore(CollectionHandlerStore collectionHandlerStore, BeanUnproxy beanUnproxy) {
+ private final BeanPropertySelector beanPropertySelector;
+
+ public BeanMatchStore(CollectionHandlerStore collectionHandlerStore, BeanUnproxy beanUnproxy, BeanPropertySelector beanPropertySelector) {
this.collectionHandlerStore = collectionHandlerStore;
this.beanUnproxy = beanUnproxy;
+ this.beanPropertySelector = beanPropertySelector;
}
public void validateStrictBeanPairs(List beanPairs) {
@@ -82,11 +79,7 @@ public BeanMatch getBeanMatch(BeanPair beanPair) {
}
public BeanMatch addBeanMatch(BeanMatch beanMatch) {
- Map, BeanMatch> targetsForSource = store.get(beanMatch.getSourceClass());
- if (targetsForSource == null) {
- targetsForSource = new HashMap<>();
- store.put(beanMatch.getSourceClass(), targetsForSource);
- }
+ Map, BeanMatch> targetsForSource = store.computeIfAbsent(beanMatch.getSourceClass(), k -> new HashMap<>());
targetsForSource.put(beanMatch.getTargetClass(), beanMatch);
return beanMatch;
}
@@ -94,7 +87,7 @@ public BeanMatch addBeanMatch(BeanMatch beanMatch) {
private BeanMatch determineBeanMatch(BeanPair beanPair) {
Map sourceNode = new HashMap<>();
- MaptargetNode = new HashMap<>();
+ Map targetNode = new HashMap<>();
Map aliases = new HashMap<>();
return new BeanMatch(
@@ -106,7 +99,9 @@ private BeanMatch determineBeanMatch(BeanPair beanPair) {
beanPair.getSourceClass(),
beanPair.getTargetClass(),
null,
- BeanPropertyMatchupDirection.SOURCE_TO_TARGET),
+
+ BeanPropertyMatchupDirection.SOURCE_TO_TARGET
+ ),
getAllFields(
targetNode,
sourceNode,
@@ -114,8 +109,11 @@ private BeanMatch determineBeanMatch(BeanPair beanPair) {
beanPair.getTargetClass(),
beanPair.getSourceClass(),
null,
- BeanPropertyMatchupDirection.TARGET_TO_SOURCE),
- aliases);
+
+ BeanPropertyMatchupDirection.TARGET_TO_SOURCE
+ ),
+ aliases
+ );
}
private Map getAllFields(Map ourNodes, Map otherNodes, Map aliases,
@@ -126,46 +124,28 @@ private Map getAllFields(Map ourNode
for (PropertyAccessor accessor : accessors) {
BeanPropertyAccessType accessType = matchupDirection.accessType(accessor);
- if (accessType == BeanPropertyAccessType.NO_ACCESS) {
- continue;
- }
-
- // Ignore fields
- if (accessor.isAnnotationPresent(BeanIgnore.class)) {
+ if (accessType == BeanPropertyAccessType.NO_ACCESS || accessor.isAnnotationPresent(BeanIgnore.class)) {
continue;
}
// BeanProperty allows the field to match with a field from the other side with a different name
// and even a different nesting level.
- BeanPropertyWrapper beanPropertyWrapper;
- try {
- beanPropertyWrapper = dealWithBeanProperty(matchupDirection, otherNodes, otherType, accessor);
- } catch (IntrospectionException e) {
- throw new RuntimeException(e);
- }
+ BeanPropertyWrapper beanPropertyWrapper = beanPropertySelector.dealWithBeanProperty(matchupDirection, otherNodes, otherType, accessor);
// Unwrap the fields which exist in the unwrap class
BeanProperty currentBeanProperty;
try {
- currentBeanProperty = new BeanPropertyCreator(
- matchupDirection, ourType, accessor.getName()).determineNodesForPath(precedingBeanProperty);
+ currentBeanProperty = new BeanPropertyCreator(matchupDirection, ourType, accessor.getName()).determineNodesForPath(precedingBeanProperty);
currentBeanProperty.setMustMatch(beanPropertyWrapper.isMustMatch());
} catch (BeanNoSuchPropertyException e) {
throw new BeanMissingPathException(ourType, accessor.getName(), e);
}
- handleBeanCollectionAnnotation(
- accessor.findAnnotation(BeanCollection.class),
- currentBeanProperty,
- matchupDirection);
+ handleBeanCollectionAnnotation(accessor.findAnnotation(BeanCollection.class), currentBeanProperty, matchupDirection);
- handleBeanRoleSecuredAnnotation(
- currentBeanProperty,
- accessor.findAnnotation(BeanRoleSecured.class));
+ handleBeanRoleSecuredAnnotation(currentBeanProperty, accessor.findAnnotation(BeanRoleSecured.class));
- handleBeanLogicSecuredAnnotation(
- currentBeanProperty,
- accessor.findAnnotation(BeanLogicSecured.class));
+ handleBeanLogicSecuredAnnotation(currentBeanProperty, accessor.findAnnotation(BeanLogicSecured.class));
if (accessor.isAnnotationPresent(BeanAlias.class)) {
BeanAlias beanAlias = accessor.findAnnotation(BeanAlias.class);
@@ -176,14 +156,7 @@ private Map getAllFields(Map ourNode
}
if (accessor.isAnnotationPresent(BeanUnwrap.class)) {
- ourCurrentNodes = getAllFields(
- ourCurrentNodes,
- otherNodes,
- aliases,
- accessor.getType(),
- otherType,
- currentBeanProperty,
- matchupDirection);
+ ourCurrentNodes = getAllFields(ourCurrentNodes, otherNodes, aliases, accessor.getType(), otherType, currentBeanProperty, matchupDirection);
} else {
ourCurrentNodes.put(beanPropertyWrapper.getName(), currentBeanProperty);
}
@@ -205,10 +178,7 @@ private void handleBeanRoleSecuredAnnotation(BeanProperty beanProperty, BeanRole
beanProperty.setRequiredRoles(beanRoleSecured.value());
}
- private void handleBeanCollectionAnnotation(
- BeanCollection beanCollection,
- BeanProperty beanProperty,
- BeanPropertyMatchupDirection matchupDirection) {
+ private void handleBeanCollectionAnnotation(BeanCollection beanCollection, BeanProperty beanProperty, BeanPropertyMatchupDirection matchupDirection) {
CollectionElementType elementType = EMPTY_COLLECTION_ELEMENT_TYPE;
BeanCollectionUsage beanCollectionUsage = null;
@@ -238,8 +208,7 @@ private void handleBeanCollectionAnnotation(
if (collectionHandler == null) {
collectionHandler = getCollectionHandlerFor(beanProperty);
}
- Class> genericClassOfField =
- beanProperty.getGenericClassOfField(collectionHandler.getGenericParameterIndex());
+ Class> genericClassOfField = beanProperty.getGenericClassOfField(collectionHandler.getGenericParameterIndex());
if (genericClassOfField != null) {
elementType = derived(genericClassOfField);
}
@@ -256,93 +225,4 @@ private void handleBeanCollectionAnnotation(
private CollectionHandler getCollectionHandlerFor(BeanProperty beanProperty) {
return collectionHandlerStore.getCollectionHandlerFor(beanProperty.getAccessor().getType(), beanUnproxy);
}
-
- private BeanPropertyWrapper dealWithBeanProperty(BeanPropertyMatchupDirection matchupDirection, Map otherNodes, Class> otherType,
- PropertyAccessor accessor) throws IntrospectionException {
- BeanPropertyWrapper wrapper = new BeanPropertyWrapper(accessor.getName());
- if (accessor.isAnnotationPresent(io.beanmapper.annotations.BeanProperty.class)) {
- io.beanmapper.annotations.BeanProperty beanProperty = accessor.findAnnotation(io.beanmapper.annotations.BeanProperty.class);
-
- detectBeanPropertyFieldShadowing(accessor, beanProperty);
-
- wrapper.setMustMatch();
- wrapper.setName(getBeanPropertyName(beanProperty));
- // Get the other field from the location that is specified in the beanProperty annotation.
- // If the field is referred to by a path, store the custom field in the other map
- try {
- otherNodes.put(
- wrapper.getName(),
- new BeanPropertyCreator(matchupDirection.getInverse(), otherType, wrapper.getName())
- .determineNodesForPath());
- } catch (BeanNoSuchPropertyException err) {
- BeanMapperTraceLogger.log("""
- BeanNoSuchPropertyException thrown by BeanMatchStore#dealWithBeanProperty(BeanPropertyMatchupDirection, Map, Class, PropertyAccessor), for {}.
- {}""", wrapper.getName(), err.getMessage());
- }
- }
- return wrapper;
- }
-
- private String getBeanPropertyName(io.beanmapper.annotations.BeanProperty annotation) {
- return annotation.value().isBlank() ? annotation.name() : annotation.value();
- }
-
- /**
- * Detects whether a field annotated with the BeanProperty-annotation shadows an existing, accessible field.
- *
- * In this context, an accessible field is any field which is either public, or exposes a public
- * accessor-method.
- *
- * @param accessor The accessor that can be used to access to the value within the field.
- * @param beanProperty The BeanProperty-annotation annotating the relevant field.
- * @throws IntrospectionException May be thrown whenever an Exception occurs during the introspection of the
- * relevant bean.
- */
- private void detectBeanPropertyFieldShadowing(final PropertyAccessor accessor, final io.beanmapper.annotations.BeanProperty beanProperty)
- throws IntrospectionException {
- var beanPropertyName = getBeanPropertyName(beanProperty);
-
- var fields = accessor.getDeclaringClass().getDeclaredFields();
- for (var field : fields) {
- var fieldName = field.getName();
- if (!fieldName.equals(accessor.getName())
- && !field.isAnnotationPresent(BeanIgnore.class)
- && fieldName.equals(beanPropertyName)
- && (Modifier.isPublic(field.getModifiers())
- || hasAccessibleWriteMethod(field))) {
- if (field.isAnnotationPresent(io.beanmapper.annotations.BeanProperty.class)) {
- var fieldBeanProperty = field.getAnnotation(io.beanmapper.annotations.BeanProperty.class);
- if (getBeanPropertyName(fieldBeanProperty).equals(beanPropertyName))
- throw new FieldShadowingException(
- String.format("%s %s.%s shadows %s.%s.", beanProperty, accessor.getDeclaringClass().getName(), accessor.getName(),
- field.getDeclaringClass().getName(), fieldName));
- } else {
- throw new FieldShadowingException(
- String.format("%s %s.%s shadows %s.%s.", beanProperty, accessor.getDeclaringClass().getName(), accessor.getName(),
- field.getDeclaringClass().getName(), fieldName));
- }
- }
- }
- }
-
- /**
- * Determines whether a field exposes an accessible mutator-method.
- *
- *
This method first retrieves the array of PropertyDescriptors, and turns it into a Stream. If any of the
- * PropertyDescriptor-objects have the same name as the field, this method returns true. If not, this method returns
- * false.
- *
- * In this context, an accessible mutator-method is any method which is public and adheres to the Java
- * Beans definition of a mutator.
- *
- * @param field The Field of which the method attempts to find a mutator-method.
- * @return True, if the field exposes an accessible mutator, false otherwise.
- * @throws IntrospectionException May be thrown whenever an Exception occurs during the introspection of the
- * relevant bean.
- */
- private boolean hasAccessibleWriteMethod(final Field field) throws IntrospectionException {
- return Arrays.stream(Introspector.getBeanInfo(field.getDeclaringClass()).getPropertyDescriptors())
- .anyMatch(propertyDescriptor -> propertyDescriptor.getName().equals(field.getName()));
- }
-
}
diff --git a/src/main/java/io/beanmapper/core/BeanPropertyWrapper.java b/src/main/java/io/beanmapper/core/BeanPropertyWrapper.java
index b0a02e32..85fb239d 100644
--- a/src/main/java/io/beanmapper/core/BeanPropertyWrapper.java
+++ b/src/main/java/io/beanmapper/core/BeanPropertyWrapper.java
@@ -1,5 +1,7 @@
package io.beanmapper.core;
+import java.util.Objects;
+
public class BeanPropertyWrapper {
private String name;
private boolean mustMatch = false;
@@ -23,4 +25,23 @@ public boolean isMustMatch() {
public void setMustMatch() {
this.mustMatch = true;
}
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null || getClass() != obj.getClass())
+ return false;
+
+ BeanPropertyWrapper that = (BeanPropertyWrapper) obj;
+
+ if (mustMatch != that.mustMatch)
+ return false;
+ return Objects.equals(name, that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, mustMatch);
+ }
}
diff --git a/src/main/java/io/beanmapper/core/inspector/BeanPropertySelector.java b/src/main/java/io/beanmapper/core/inspector/BeanPropertySelector.java
new file mode 100644
index 00000000..b84fcb35
--- /dev/null
+++ b/src/main/java/io/beanmapper/core/inspector/BeanPropertySelector.java
@@ -0,0 +1,193 @@
+package io.beanmapper.core.inspector;
+
+import java.beans.IntrospectionException;
+import java.beans.Introspector;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import io.beanmapper.annotations.BeanIgnore;
+import io.beanmapper.annotations.BeanProperty;
+import io.beanmapper.core.BeanPropertyCreator;
+import io.beanmapper.core.BeanPropertyMatchupDirection;
+import io.beanmapper.core.BeanPropertyWrapper;
+import io.beanmapper.exceptions.BeanNoSuchPropertyException;
+import io.beanmapper.exceptions.DuplicateBeanPropertyTargetException;
+import io.beanmapper.exceptions.FieldShadowingException;
+import io.beanmapper.utils.BeanMapperTraceLogger;
+
+public class BeanPropertySelector {
+
+ /**
+ * Determines whether a field exposes an accessible mutator-method.
+ *
+ *
This method first retrieves the array of PropertyDescriptors, and turns it into a Stream. If any of the
+ * PropertyDescriptor-objects have the same name as the field, this method returns true. If not, this method returns
+ * false.
+ *
+ * In this context, an accessible mutator-method is any method which is public and adheres to the Java
+ * Beans definition of a mutator.
+ *
+ * @param clazz Class containing the field.
+ * @param fieldName Name of the field.
+ * @return True, if the field exposes an accessible mutator, false otherwise.
+ * @throws IntrospectionException May be thrown whenever an Exception occurs during the introspection of the
+ * relevant bean.
+ */
+ public boolean hasAccessibleWriteMethod(final Class clazz, String fieldName) throws IntrospectionException {
+ if (clazz == null || fieldName == null) {
+ return false;
+ }
+
+ return Arrays.stream(Introspector.getBeanInfo(clazz).getPropertyDescriptors())
+ .anyMatch(propertyDescriptor -> propertyDescriptor.getName().equals(fieldName));
+ }
+
+ public String getBeanPropertyName(BeanProperty annotation) {
+ return annotation.name().isBlank() ? annotation.value() : annotation.name();
+ }
+
+ private void validateHasNoMoreThanOneRelevantBeanProperty(List beanProperties, Class otherType) {
+ if (beanProperties.size() > 1) {
+ throw new DuplicateBeanPropertyTargetException("(Target: %s)".formatted(otherType.getSimpleName()));
+ }
+ }
+
+ private void validateHasNoMoreThanOneWildcardBeanProperty(List beanProperties) {
+ if (beanProperties.size() > 1) {
+ throw new DuplicateBeanPropertyTargetException(
+ "(Target: any; Only one BeanProperty may have Void.class (the wildcard-type for BeanProperties) used in its targets-property.)");
+ }
+ }
+
+ private BeanProperty handlePossibleWildcardBeanProperties(BeanProperty.BeanProperties beanProperties) {
+ var wildCardBeanProperties = Arrays.stream(beanProperties.value())
+ .filter(beanProperty -> Arrays.stream(beanProperty.targets()).anyMatch(clazz -> clazz == BeanProperty.WILDCARD_TYPE))
+ .toList();
+ if (wildCardBeanProperties.isEmpty()) {
+ return null;
+ }
+ validateHasNoMoreThanOneWildcardBeanProperty(wildCardBeanProperties);
+ return wildCardBeanProperties.get(0);
+ }
+
+ /**
+ * Determines which BeanProperty-annotation should be used for the mapping of the current field, based on the type of the class on the opposite side of the mapping.
+ *
+ * If a single BeanProperty contains the otherType in its targets-property, that BeanProperty will be selected.
+ * If none of the BeanProperty-annotation contain the otherType in its targets-property, we look for a BeanProperty with the Void-type in its targets-property.
+ * If multiple BeanProperty-annotations contain the otherType in its targets-property, an Exception is thrown, to indicate that it is not possible to determine a BeanProperty to use.
+ * If no relevant BeanProperty can be found, this method returns null.
+ *