From aed96e75da377b31074a8fec7f579e915047244f Mon Sep 17 00:00:00 2001 From: Marcus Talbot Date: Sat, 13 Jul 2024 14:47:26 +0200 Subject: [PATCH] Make BeanProperty repeatable - Made BeanProperty repeatable. - Added BeanProperty#targets, allowing users to specify which mappings the specified BeanProperty should apply to. - Compatibility with existing usages of the BeanProperty-annotation. Closes: #188 --- CHANGELOG.md | 6 + pom.xml | 8 +- .../beanmapper/annotations/BeanProperty.java | 23 + .../beanmapper/config/CoreConfiguration.java | 3 +- .../io/beanmapper/core/BeanMatchStore.java | 168 +------ .../beanmapper/core/BeanPropertyWrapper.java | 21 + .../core/inspector/BeanPropertySelector.java | 193 +++++++ .../beanmapper/dynclass/ClassGenerator.java | 3 +- .../DuplicateBeanPropertyTargetException.java | 10 + .../exceptions/FieldShadowingException.java | 5 +- .../annotations/BeanPropertyTest.java | 74 +++ .../ComplexInvalidPersonForm.java | 20 + .../model/bean_property/ComplexPerson.java | 41 ++ .../bean_property/ComplexPersonResult.java | 10 + .../bean_property/InvalidPersonForm.java | 20 + .../model/bean_property/Person.java | 23 + .../model/bean_property/PersonForm.java | 25 + .../model/bean_property/PersonResult.java | 11 + .../bean_property/ShadowedPersonResult.java | 21 + .../config/BeanMapperBuilderTest.java | 7 +- .../collections/SetCollectionHandlerTest.java | 1 - .../inspector/BeanPropertySelectorTest.java | 471 ++++++++++++++++++ .../BeanPropertyImpl.java | 38 ++ .../strategy/ConstructorArgumentsTest.java | 7 +- 24 files changed, 1051 insertions(+), 158 deletions(-) create mode 100644 src/main/java/io/beanmapper/core/inspector/BeanPropertySelector.java create mode 100644 src/main/java/io/beanmapper/exceptions/DuplicateBeanPropertyTargetException.java create mode 100644 src/test/java/io/beanmapper/annotations/BeanPropertyTest.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/ComplexInvalidPersonForm.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPerson.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPersonResult.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/InvalidPersonForm.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/Person.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/PersonForm.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/PersonResult.java create mode 100644 src/test/java/io/beanmapper/annotations/model/bean_property/ShadowedPersonResult.java create mode 100644 src/test/java/io/beanmapper/core/inspector/BeanPropertySelectorTest.java create mode 100644 src/test/java/io/beanmapper/core/inspector/models/bean_property_selector/BeanPropertyImpl.java 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.13 2.17.0 - 5.10.2 + 5.10.3 3.13.0 3.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. + *

+ * + * @param beanProperties All BeanProperty-annotations applied to the current field. + * @param otherType The type of the class on the other side of the mapping. + * @return The most relevant BeanProperty. + */ + public BeanProperty determineRelevantBeanPropertyForBeanMatch( + BeanProperty.BeanProperties beanProperties, Class otherType) { + List relevantBeanProperties = Arrays.stream(beanProperties.value()) + .filter(beanProperty -> Arrays.stream(beanProperty.targets()) + .anyMatch(pairedBean -> pairedBean == otherType)) + .toList(); + + validateHasNoMoreThanOneRelevantBeanProperty(relevantBeanProperties, otherType); + + if (relevantBeanProperties.isEmpty()) { + return handlePossibleWildcardBeanProperties(beanProperties); + } + + return relevantBeanProperties.get(0); + } + + public boolean hasBeanProperty(PropertyAccessor accessor) { + return accessor.isAnnotationPresent(BeanProperty.class) || accessor.isAnnotationPresent(BeanProperty.BeanProperties.class); + } + + public boolean hasValidTarget(Class otherType, Class[] targets) { + return Arrays.stream(targets).anyMatch(clazz -> clazz == otherType || clazz == Void.class); + } + + public BeanProperty getUnhandledBeanProperty(PropertyAccessor accessor, Class otherType) { + if (!hasBeanProperty(accessor)) { + return null; + } + + BeanProperty beanProperty; + + if (accessor.isAnnotationPresent(BeanProperty.class)) { + beanProperty = accessor.findAnnotation(BeanProperty.class); + return hasValidTarget(otherType, beanProperty.targets()) ? beanProperty : null; + } + + BeanProperty.BeanProperties beanProperties = accessor.findAnnotation(BeanProperty.BeanProperties.class); + beanProperty = determineRelevantBeanPropertyForBeanMatch(beanProperties, otherType); + + return beanProperty; + } + + public BeanPropertyWrapper dealWithBeanProperty(BeanPropertyMatchupDirection matchupDirection, Map otherNodes, + Class otherType, PropertyAccessor accessor) { + BeanPropertyWrapper wrapper = new BeanPropertyWrapper(accessor.getName()); + + BeanProperty beanProperty = getUnhandledBeanProperty(accessor, otherType); + + if (beanProperty == null) { + return wrapper; + } + + 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; + } + + public void detectBeanPropertyFieldShadowing(final PropertyAccessor accessor, final BeanProperty beanProperty) { + var beanPropertyName = getBeanPropertyName(beanProperty); + Arrays.stream(accessor.getDeclaringClass().getDeclaredFields()) + .filter(field -> shadowsField(accessor, field, beanPropertyName)) + .filter(field -> hasBeanPropertyAnnotationAndOtherPropertyName(field, beanPropertyName)) + .findAny() + .ifPresent(field -> { + throw new FieldShadowingException( + String.format("%s %s.%s shadows %s.%s.", beanProperty, accessor.getDeclaringClass().getName(), accessor.getName(), + field.getDeclaringClass().getName(), field.getName() + )); + }); + } + + public boolean hasBeanPropertyAnnotationAndOtherPropertyName(Field field, String beanPropertyName) { + return !field.isAnnotationPresent(BeanProperty.class) || getBeanPropertyName(field.getAnnotation(BeanProperty.class)).equals(beanPropertyName); + } + + public boolean shadowsField(PropertyAccessor accessor, Field field, String beanPropertyName) { + try { + boolean fieldNameNotEqualToAccessorName = isFieldNameNotEqualToAccessorName(accessor, field); + boolean isFieldAccessible = isFieldAccessible(field); + return fieldNameNotEqualToAccessorName && !field.isAnnotationPresent(BeanIgnore.class) && field.getName().equals(beanPropertyName) + && isFieldAccessible; + } catch (IntrospectionException ex) { + throw new FieldShadowingException("Could verify lack of field shadowing. IntrospectionException: " + ex.getMessage()); + } + } + + public boolean isFieldAccessible(Field field) throws IntrospectionException { + return Modifier.isPublic(field.getModifiers()) || hasAccessibleWriteMethod(field.getDeclaringClass(), field.getName()); + } + + public boolean isFieldNameNotEqualToAccessorName(PropertyAccessor accessor, Field field) { + return !field.getName().equals(accessor.getName()); + } +} diff --git a/src/main/java/io/beanmapper/dynclass/ClassGenerator.java b/src/main/java/io/beanmapper/dynclass/ClassGenerator.java index dde5022b..e2fe718d 100644 --- a/src/main/java/io/beanmapper/dynclass/ClassGenerator.java +++ b/src/main/java/io/beanmapper/dynclass/ClassGenerator.java @@ -9,6 +9,7 @@ import io.beanmapper.core.BeanMatchStore; import io.beanmapper.core.BeanProperty; import io.beanmapper.core.converter.collections.BeanCollectionInstructions; +import io.beanmapper.core.inspector.BeanPropertySelector; import javassist.CannotCompileException; import javassist.ClassClassPath; import javassist.ClassMap; @@ -34,7 +35,7 @@ public ClassGenerator() { } public ClassGenerator(ClassPool classPool) { - this.beanMatchStore = new BeanMatchStore(null, null); + this.beanMatchStore = new BeanMatchStore(null, null, new BeanPropertySelector()); this.classPool = classPool; } diff --git a/src/main/java/io/beanmapper/exceptions/DuplicateBeanPropertyTargetException.java b/src/main/java/io/beanmapper/exceptions/DuplicateBeanPropertyTargetException.java new file mode 100644 index 00000000..84d31a6c --- /dev/null +++ b/src/main/java/io/beanmapper/exceptions/DuplicateBeanPropertyTargetException.java @@ -0,0 +1,10 @@ +package io.beanmapper.exceptions; + +public class DuplicateBeanPropertyTargetException extends BeanMappingException { + + private static final String MESSAGE_TEMPLATE = "Multiple BeanProperty-annotations for a single property, contain reference to the same target-class. %s"; + + public DuplicateBeanPropertyTargetException(String message) { + super(MESSAGE_TEMPLATE.formatted(message)); + } +} diff --git a/src/main/java/io/beanmapper/exceptions/FieldShadowingException.java b/src/main/java/io/beanmapper/exceptions/FieldShadowingException.java index 5a8ba058..ac970d83 100644 --- a/src/main/java/io/beanmapper/exceptions/FieldShadowingException.java +++ b/src/main/java/io/beanmapper/exceptions/FieldShadowingException.java @@ -1,7 +1,10 @@ package io.beanmapper.exceptions; public class FieldShadowingException extends BeanMappingException { + + private static final String MESSAGE_TEMPLATE = "Field Shadowing detected; one or more fields are shadowed by occurrence(s) of BeanProperty. %s"; + public FieldShadowingException(String message) { - super(message); + super(MESSAGE_TEMPLATE.formatted(message)); } } diff --git a/src/test/java/io/beanmapper/annotations/BeanPropertyTest.java b/src/test/java/io/beanmapper/annotations/BeanPropertyTest.java new file mode 100644 index 00000000..69191383 --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/BeanPropertyTest.java @@ -0,0 +1,74 @@ +package io.beanmapper.annotations; + +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 io.beanmapper.BeanMapper; +import io.beanmapper.annotations.model.bean_property.ComplexInvalidPersonForm; +import io.beanmapper.annotations.model.bean_property.ComplexPerson; +import io.beanmapper.annotations.model.bean_property.ComplexPersonResult; +import io.beanmapper.annotations.model.bean_property.InvalidPersonForm; +import io.beanmapper.annotations.model.bean_property.Person; +import io.beanmapper.annotations.model.bean_property.PersonForm; +import io.beanmapper.annotations.model.bean_property.PersonResult; +import io.beanmapper.annotations.model.bean_property.ShadowedPersonResult; +import io.beanmapper.config.BeanMapperBuilder; +import io.beanmapper.exceptions.DuplicateBeanPropertyTargetException; +import io.beanmapper.exceptions.FieldShadowingException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class BeanPropertyTest { + + private BeanMapper beanMapper; + + @BeforeEach + void prepareBeanMapper() { + beanMapper = new BeanMapperBuilder() + .setApplyStrictMappingConvention(false) + .addPackagePrefix(BeanMapper.class) + .build(); + } + + @Test + void testMultipleBeanPropertiesFormToEntity() { + var personForm = new PersonForm("Henk", "Jan"); + var person = assertDoesNotThrow(() -> beanMapper.map(personForm, Person.class)); + assertEquals("Henk Jan", person.getName()); + } + + @Test + void testMultipleBeanPropertiesEntityToResult() { + var person = new Person("Henk Jan"); + var personResult = assertDoesNotThrow(() -> beanMapper.map(person, PersonResult.class)); + assertEquals("Henk Jan", personResult.fullName); + } + + @Test + void testMultipleBeanPropertiesFormToResult() { + var personForm = new PersonForm("Henk", "Jan"); + var personResult = assertDoesNotThrow(() -> beanMapper.map(personForm, PersonResult.class)); + assertEquals("Henk Jan", personResult.fullName); + } + + @Test + void testInvalidBeanPropertiesShouldThrow() { + var personForm = new InvalidPersonForm("Henk", "Jan"); + assertThrows(DuplicateBeanPropertyTargetException.class, () -> beanMapper.map(personForm, Person.class)); + } + + @Test + void testComplexInvalidBeanPropertiesShouldThrow() { + var personForm = new ComplexInvalidPersonForm("Henk", "Jan"); + assertThrows(DuplicateBeanPropertyTargetException.class, () -> beanMapper.map(personForm, ComplexPerson.class)); + assertThrows(DuplicateBeanPropertyTargetException.class, () -> beanMapper.map(personForm, ComplexPersonResult.class)); + } + + @Test + void testBeanPropertyShadowingDetected() { + var shadedPersonForm = new PersonForm("Henk", "Jan"); + assertThrows(FieldShadowingException.class, () -> beanMapper.map(shadedPersonForm, ShadowedPersonResult.class)); + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexInvalidPersonForm.java b/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexInvalidPersonForm.java new file mode 100644 index 00000000..0cfcb446 --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexInvalidPersonForm.java @@ -0,0 +1,20 @@ +package io.beanmapper.annotations.model.bean_property; + +import io.beanmapper.annotations.BeanProperty; + +public class ComplexInvalidPersonForm { + + @BeanProperty("firstName") + @BeanProperty(name = "firstName", targets = { ComplexPerson.class, ComplexPersonResult.class }) + @BeanProperty(name = "f_name", targets = { ComplexPerson.class, ComplexPersonResult.class }) + public String firstName; + + @BeanProperty(name = "lastName", targets = { ComplexPerson.class, ComplexPersonResult.class }) + @BeanProperty(name = "l_name", targets = { ComplexPerson.class, ComplexPersonResult.class }) + public String lastName; + + public ComplexInvalidPersonForm(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPerson.java b/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPerson.java new file mode 100644 index 00000000..89a3e16f --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPerson.java @@ -0,0 +1,41 @@ +package io.beanmapper.annotations.model.bean_property; + +public class ComplexPerson { + + private String f_name; + private String l_name; + private String firstName; + private String lastName; + + public void setF_name(String f_name) { + this.f_name = f_name; + } + + public String getF_name() { + return f_name; + } + + public void setL_name(String l_name) { + this.l_name = l_name; + } + + public String getL_name() { + return l_name; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getFirstName() { + return firstName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getLastName() { + return lastName; + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPersonResult.java b/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPersonResult.java new file mode 100644 index 00000000..61df68ec --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/ComplexPersonResult.java @@ -0,0 +1,10 @@ +package io.beanmapper.annotations.model.bean_property; + +public class ComplexPersonResult { + + public String f_name; + public String l_name; + public String firstName; + public String lastName; + +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/InvalidPersonForm.java b/src/test/java/io/beanmapper/annotations/model/bean_property/InvalidPersonForm.java new file mode 100644 index 00000000..63654fe6 --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/InvalidPersonForm.java @@ -0,0 +1,20 @@ +package io.beanmapper.annotations.model.bean_property; + +import io.beanmapper.annotations.BeanProperty; + +public class InvalidPersonForm { + + private String firstName; + private String lastName; + + public InvalidPersonForm(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + @BeanProperty("name") + @BeanProperty("fullName") + public String getFirstName() { + return firstName; + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/Person.java b/src/test/java/io/beanmapper/annotations/model/bean_property/Person.java new file mode 100644 index 00000000..90bc823b --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/Person.java @@ -0,0 +1,23 @@ +package io.beanmapper.annotations.model.bean_property; + +import io.beanmapper.annotations.BeanProperty; + +public class Person { + + private String name; + + public Person() {} + + public Person(String name) { + this.name = name; + } + + public void setName(String name) { + this.name = name; + } + + @BeanProperty(name = "fullName", targets = { PersonResult.class }) + public String getName() { + return name; + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/PersonForm.java b/src/test/java/io/beanmapper/annotations/model/bean_property/PersonForm.java new file mode 100644 index 00000000..a2e62ace --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/PersonForm.java @@ -0,0 +1,25 @@ +package io.beanmapper.annotations.model.bean_property; + +import io.beanmapper.annotations.BeanIgnore; +import io.beanmapper.annotations.BeanProperty; + +public class PersonForm { + + public String firstName; + public String lastName; + @BeanIgnore + @BeanProperty("ignored") + public String ignored; + + public PersonForm(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + @BeanProperty(name = "name") + @BeanProperty(name = "name", targets = Person.class) + @BeanProperty(name = "fullName", targets = PersonResult.class) + public String getFullName() { + return "%s %s".formatted(firstName, lastName); + } +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/PersonResult.java b/src/test/java/io/beanmapper/annotations/model/bean_property/PersonResult.java new file mode 100644 index 00000000..751d01af --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/PersonResult.java @@ -0,0 +1,11 @@ +package io.beanmapper.annotations.model.bean_property; + +import io.beanmapper.annotations.BeanProperty; + +public class PersonResult { + + @BeanProperty(value = "henk", targets = String.class) + @BeanProperty(value = "fullName", targets = PersonResult.class) + public String fullName; + +} diff --git a/src/test/java/io/beanmapper/annotations/model/bean_property/ShadowedPersonResult.java b/src/test/java/io/beanmapper/annotations/model/bean_property/ShadowedPersonResult.java new file mode 100644 index 00000000..2f1068fe --- /dev/null +++ b/src/test/java/io/beanmapper/annotations/model/bean_property/ShadowedPersonResult.java @@ -0,0 +1,21 @@ +package io.beanmapper.annotations.model.bean_property; + +import io.beanmapper.annotations.BeanProperty; + +public class ShadowedPersonResult { + + @BeanProperty("lastName") + public String firstName; + @BeanProperty("lastName") + public String lastName; + + public ShadowedPersonResult(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + @BeanProperty("lastName") + public void setFirstName(String lastName) { + this.lastName = lastName; + } +} diff --git a/src/test/java/io/beanmapper/config/BeanMapperBuilderTest.java b/src/test/java/io/beanmapper/config/BeanMapperBuilderTest.java index 70a2f406..893f21ab 100644 --- a/src/test/java/io/beanmapper/config/BeanMapperBuilderTest.java +++ b/src/test/java/io/beanmapper/config/BeanMapperBuilderTest.java @@ -140,12 +140,7 @@ public T instantiate(Class beanClass, ConstructorArguments arguments) { @Test @Disabled("BeanUnproxy should not be used as a Converter.") void setBeanUnproxy() { - final BeanUnproxy expectedBeanUnproxy = new BeanUnproxy() { - @Override - public Class unproxy(Class beanClass) { - return Long.class; - } - }; + final BeanUnproxy expectedBeanUnproxy = beanClass -> Long.class; BeanMapper beanMapper = new BeanMapperBuilder() .setBeanUnproxy(expectedBeanUnproxy) .build(); diff --git a/src/test/java/io/beanmapper/core/collections/SetCollectionHandlerTest.java b/src/test/java/io/beanmapper/core/collections/SetCollectionHandlerTest.java index 2f4f6127..cc6b80f9 100644 --- a/src/test/java/io/beanmapper/core/collections/SetCollectionHandlerTest.java +++ b/src/test/java/io/beanmapper/core/collections/SetCollectionHandlerTest.java @@ -23,5 +23,4 @@ void createHashSet() { Set set = collectionHandler.create(ComparableClass.class); assertEquals(TreeSet.class, set.getClass()); } - } diff --git a/src/test/java/io/beanmapper/core/inspector/BeanPropertySelectorTest.java b/src/test/java/io/beanmapper/core/inspector/BeanPropertySelectorTest.java new file mode 100644 index 00000000..8d1d8165 --- /dev/null +++ b/src/test/java/io/beanmapper/core/inspector/BeanPropertySelectorTest.java @@ -0,0 +1,471 @@ +package io.beanmapper.core.inspector; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.when; + +import java.beans.IntrospectionException; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.HashMap; + +import io.beanmapper.annotations.BeanIgnore; +import io.beanmapper.annotations.BeanProperty; +import io.beanmapper.annotations.model.bean_property.Person; +import io.beanmapper.annotations.model.bean_property.PersonResult; +import io.beanmapper.core.BeanPropertyMatchupDirection; +import io.beanmapper.core.BeanPropertyWrapper; +import io.beanmapper.core.inspector.models.bean_property_selector.BeanPropertyImpl; +import io.beanmapper.exceptions.DuplicateBeanPropertyTargetException; +import io.beanmapper.exceptions.FieldShadowingException; +import io.beanmapper.utils.BeanMapperTraceLogger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class BeanPropertySelectorTest { + + private BeanPropertySelector selector; + + @BeforeEach + void setUp() { + selector = spy(new BeanPropertySelector()); + } + + @Test + void testHasAccessibleWriteMethodReturnsTrueWhenAvailable() { + assertTrue(assertDoesNotThrow(() -> selector.hasAccessibleWriteMethod(Person.class, "name"))); + } + + @Test + void testHasAccessibleWriteMethodReturnsFalseWhenNotAvailable() { + assertFalse(assertDoesNotThrow(() -> selector.hasAccessibleWriteMethod(Person.class, "noName"))); + } + + @Test + void testHasAccessibleWriteMethodReturnsFalseWhenGivenNullClass() { + assertFalse(assertDoesNotThrow(() -> selector.hasAccessibleWriteMethod(null, ""))); + } + + @Test + void testHasAccessibleWriteMethodReturnsFalseWhenGivenNullName() { + assertFalse(assertDoesNotThrow(() -> selector.hasAccessibleWriteMethod(Person.class, null))); + } + + @Test + void testGetBeanPropertyNameWithNameProperty() { + BeanProperty beanProperty = new BeanPropertyImpl("Henk", "Jan", new Class[] { Person.class }); + assertEquals("Henk", selector.getBeanPropertyName(beanProperty)); + } + + @Test + void testGetBeanPropertyNameWithBlankName() { + BeanProperty beanProperty = new BeanPropertyImpl("", "Jan", new Class[] { Person.class }); + assertEquals("Jan", selector.getBeanPropertyName(beanProperty)); + } + + @Test + void testHasBeanPropertyReturnsTrueBeanPropertyAvailable() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.isAnnotationPresent(BeanProperty.class)).thenReturn(true); + assertTrue(selector.hasBeanProperty(accessor)); + } + + @Test + void testHasBeanPropertyReturnsTrueWhenBeanPropertiesAvailable() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.isAnnotationPresent(BeanProperty.class)).thenReturn(false); + when(accessor.isAnnotationPresent(BeanProperty.BeanProperties.class)).thenReturn(true); + assertTrue(selector.hasBeanProperty(accessor)); + } + + @Test + void testHasBeanPropertyReturnsFalseBeanPropertyNotAvailable() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.isAnnotationPresent(BeanProperty.class)).thenReturn(false); + when(accessor.isAnnotationPresent(BeanProperty.BeanProperties.class)).thenReturn(false); + assertFalse(selector.hasBeanProperty(accessor)); + } + + @Test + void testHasValidTargetReturnsTrueWhenOtherTypeInArray() { + assertTrue(selector.hasValidTarget(Person.class, new Class[] { Person.class })); + } + + @Test + void testHasValidTargetReturnsFalseWhenOtherTypeNotInArray() { + assertFalse(selector.hasValidTarget(Person.class, new Class[] { PersonResult.class })); + } + + @Test + void testHasValidTargetReturnsTrueWhenArrayContainsVoid() { + assertTrue(selector.hasValidTarget(Person.class, new Class[] { Void.class })); + } + + @Test + void testGetUnhandledBeanPropertyReturnsNullWhenNoValidTargetExists() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.isAnnotationPresent(BeanProperty.class)).thenReturn(true); + + BeanProperty beanProperty = new BeanPropertyImpl("Henk", "Jan", new Class[] { String.class }); + when(accessor.findAnnotation(BeanProperty.class)).thenReturn(beanProperty); + + assertNull(selector.getUnhandledBeanProperty(accessor, Person.class)); + } + + @Test + void testGetUnhandledBeanPropertyReturnsBeanPropertyWhenValidTargetExists() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.isAnnotationPresent(BeanProperty.class)).thenReturn(true); + + BeanProperty beanProperty = new BeanPropertyImpl("Henk", "Jan", new Class[] { Person.class }); + when(accessor.findAnnotation(BeanProperty.class)).thenReturn(beanProperty); + + assertEquals(beanProperty, selector.getUnhandledBeanProperty(accessor, Person.class)); + } + + @Test + void testGetUnhandledBeanPropertyReturnsBeanPropertyWhenBeanPropertiesPresent() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.isAnnotationPresent(BeanProperty.class)).thenReturn(false); + when(accessor.isAnnotationPresent(BeanProperty.BeanProperties.class)).thenReturn(true); + + BeanProperty beanProperty = new BeanPropertyImpl("Henk", "Jan", new Class[] { Person.class }); + BeanProperty.BeanProperties beanProperties = mock(BeanProperty.BeanProperties.class); + when(beanProperties.value()).thenReturn(new BeanProperty[] { beanProperty }); + when(accessor.findAnnotation(BeanProperty.BeanProperties.class)).thenReturn(beanProperties); + + assertEquals(beanProperty, selector.getUnhandledBeanProperty(accessor, Person.class)); + } + + @Test + void testGetUnhandledBeanPropertyReturnsNullWhenNoBeanPropertyExists() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.isAnnotationPresent(BeanProperty.class)).thenReturn(false); + when(accessor.isAnnotationPresent(BeanProperty.BeanProperties.class)).thenReturn(false); + + assertNull(selector.getUnhandledBeanProperty(accessor, Person.class)); + } + + @Test + void testIsFieldAccessibleReturnsTrueWhenFieldIsPublic() { + Field field = mock(Field.class); + when(field.getModifiers()).thenReturn(Modifier.PUBLIC); + assertTrue(assertDoesNotThrow(() -> selector.isFieldAccessible(field))); + } + + @Test + void testIsFieldAccessibleReturnsFalseWhenFieldIsPrivateWithNoAccessor() { + Field field = mock(Field.class); + when(field.getModifiers()).thenReturn(Modifier.PRIVATE); + when(field.getDeclaringClass()).thenReturn(null); + assertFalse(assertDoesNotThrow(() -> selector.isFieldAccessible(field))); + } + + @Test + void testFieldNameNotEqualToAccessorNameReturnsTrueWhenNotTheSame() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + Field field = mock(Field.class); + + when(field.getName()).thenReturn("fieldName"); + when(accessor.getName()).thenReturn("accessorName"); + + assertTrue(selector.isFieldNameNotEqualToAccessorName(accessor, field)); + } + + @Test + void testFieldNameNotEqualToAccessorNameReturnsFalseWhenEqual() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + Field field = mock(Field.class); + + when(field.getName()).thenReturn("name"); + when(accessor.getName()).thenReturn("name"); + + assertFalse(selector.isFieldNameNotEqualToAccessorName(accessor, field)); + } + + @Test + void testDetermineRelevantBeanPropertyForBeanMatchThrowsWhenRelevantBeanPropertiesSizeGreaterThanOne() { + BeanProperty beanProperty1 = mock(BeanProperty.class); + BeanProperty beanProperty2 = mock(BeanProperty.class); + + Class[] targets = new Class[] { String.class }; + + when(beanProperty1.targets()).thenReturn(targets); + when(beanProperty2.targets()).thenReturn(targets); + + BeanProperty.BeanProperties beanProperties = mock(BeanProperty.BeanProperties.class); + when(beanProperties.value()).thenReturn(new BeanProperty[] { beanProperty1, beanProperty2 }); + + assertThrows(DuplicateBeanPropertyTargetException.class, () -> selector.determineRelevantBeanPropertyForBeanMatch(beanProperties, String.class)); + } + + @Test + void testDetermineRelevantBeanPropertyForBeanMatchReturnsNullWhenNoRelevantBeanPropertyAndNoWildcard() { + BeanProperty.BeanProperties beanProperties = mock(BeanProperty.BeanProperties.class); + when(beanProperties.value()).thenReturn(new BeanProperty[] {}); + + assertNull(selector.determineRelevantBeanPropertyForBeanMatch(beanProperties, String.class)); + } + + @Test + void testDetermineRelevantBeanPropertyForBeanMatchThrowsWhenMultipleWildcards() { + BeanProperty beanProperty1 = mock(BeanProperty.class); + BeanProperty beanProperty2 = mock(BeanProperty.class); + + when(beanProperty1.targets()).thenReturn(new Class[] { Void.class }); + when(beanProperty2.targets()).thenReturn(new Class[] { Void.class }); + + BeanProperty.BeanProperties beanProperties = mock(BeanProperty.BeanProperties.class); + when(beanProperties.value()).thenReturn(new BeanProperty[] { beanProperty1, beanProperty2 }); + + assertThrows(DuplicateBeanPropertyTargetException.class, () -> selector.determineRelevantBeanPropertyForBeanMatch(beanProperties, String.class)); + } + + @Test + void testDetermineRelevantBeanPropertyForBeanMatchReturnsWildcardWhenNoRelevantBean() { + BeanProperty beanProperty1 = mock(BeanProperty.class); + BeanProperty beanProperty2 = mock(BeanProperty.class); + + when(beanProperty1.targets()).thenReturn(new Class[] { String.class }); + when(beanProperty2.targets()).thenReturn(new Class[] { Void.class }); + + BeanProperty.BeanProperties beanProperties = mock(BeanProperty.BeanProperties.class); + when(beanProperties.value()).thenReturn(new BeanProperty[] { beanProperty1, beanProperty2 }); + + assertEquals(beanProperty2, selector.determineRelevantBeanPropertyForBeanMatch(beanProperties, Person.class)); + } + + @Test + void testDetermineRelevantBeanPropertyForBeanMatchReturnsRelevantBeanProperty() { + BeanProperty beanProperty1 = mock(BeanProperty.class); + BeanProperty beanProperty2 = mock(BeanProperty.class); + + when(beanProperty1.targets()).thenReturn(new Class[] { String.class }); + when(beanProperty2.targets()).thenReturn(new Class[] { Person.class }); + + BeanProperty.BeanProperties beanProperties = mock(BeanProperty.BeanProperties.class); + when(beanProperties.value()).thenReturn(new BeanProperty[] { beanProperty1, beanProperty2 }); + + assertEquals(beanProperty2, selector.determineRelevantBeanPropertyForBeanMatch(beanProperties, Person.class)); + } + + @Test + void testDealWithBeanPropertyReturnsDefaultWrapperWhenNoBeanPropertyExists() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.getName()).thenReturn("henk"); + + when(selector.getUnhandledBeanProperty(accessor, Object.class)).thenReturn(null); + + BeanPropertyWrapper wrapper = new BeanPropertyWrapper(accessor.getName()); + assertEquals(wrapper, selector.dealWithBeanProperty(null, null, null, accessor)); + } + + @Test + void testDealWithBeanPropertyThrowsWhenFieldShadowingDetected() { + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(accessor.getName()).thenReturn("test"); + + BeanProperty beanProperty = mock(BeanProperty.class); + when(selector.getUnhandledBeanProperty(accessor, Void.class)).thenReturn(beanProperty); + + doThrow(FieldShadowingException.class).when(selector).detectBeanPropertyFieldShadowing(accessor, beanProperty); + + assertThrows(FieldShadowingException.class, () -> selector.dealWithBeanProperty(null, null, Void.class, accessor)); + } + + @Test + void testDealWithBeanPropertyLogsErrorWhenBeanNoSuchPropertyExceptionIsThrown() { + try (MockedStatic logger = mockStatic(BeanMapperTraceLogger.class); MockedStatic accessors = mockStatic( + PropertyAccessors.class)) { + var accessor = mock(PropertyAccessor.class); + when(accessor.getName()).thenReturn("test"); + + var beanProperty = mock(BeanProperty.class); + when(beanProperty.name()).thenReturn("test"); + when(selector.getUnhandledBeanProperty(accessor, String.class)).thenReturn(beanProperty); + doNothing().when(selector).detectBeanPropertyFieldShadowing(accessor, beanProperty); + + accessors.when(() -> PropertyAccessors.findProperty(any(), anyString())).thenReturn(null); + + var result = selector.dealWithBeanProperty(BeanPropertyMatchupDirection.SOURCE_TO_TARGET, new HashMap<>(), String.class, accessor); + var expected = new BeanPropertyWrapper(accessor.getName()); + expected.setMustMatch(); + assertEquals(expected, result); + + logger.verify(() -> BeanMapperTraceLogger.log(anyString(), anyString(), anyString()), timeout(1)); + } + } + + @Test + void testDetectBeanPropertyFieldShadowingShouldDetectBeanPropertyFieldShadowing() throws ClassNotFoundException { + PropertyAccessor accessor = mock(PropertyAccessor.class); + BeanProperty beanProperty = mock(BeanProperty.class); + String beanPropertyName = "name"; + + doReturn(beanPropertyName).when(selector).getBeanPropertyName(beanProperty); + + when(accessor.getDeclaringClass()).thenReturn((Class) Class.forName("io.beanmapper.annotations.model.bean_property.Person")); + + doReturn(true).when(selector).shadowsField(any(), any(), any()); + + doReturn(true).when(selector).hasBeanPropertyAnnotationAndOtherPropertyName(any(), any()); + + assertThrows(FieldShadowingException.class, () -> selector.detectBeanPropertyFieldShadowing(accessor, beanProperty)); + } + + @Test + void testDetectBeanPropertyFieldShadowingShouldNotDetectBeanPropertyFieldShadowingWhenNotShadowsFieldAndNotHasBeanPropertyAndOtherPropertyName() throws ClassNotFoundException { + PropertyAccessor accessor = mock(PropertyAccessor.class); + BeanProperty beanProperty = mock(BeanProperty.class); + String beanPropertyName = "name"; + + doReturn(beanPropertyName).when(selector).getBeanPropertyName(beanProperty); + + when(accessor.getDeclaringClass()).thenReturn((Class) Class.forName("io.beanmapper.annotations.model.bean_property.Person")); + + doReturn(false).when(selector).shadowsField(any(), any(), any()); + + doReturn(false).when(selector).hasBeanPropertyAnnotationAndOtherPropertyName(any(), any()); + + assertDoesNotThrow(() -> selector.detectBeanPropertyFieldShadowing(accessor, beanProperty)); + } + + @Test + void testDetectBeanPropertyFieldShadowingShouldNotDetectBeanPropertyFieldShadowingWhenNotHasBeanPropertyAndOtherBeanPropertyName() throws ClassNotFoundException { + PropertyAccessor accessor = mock(PropertyAccessor.class); + BeanProperty beanProperty = mock(BeanProperty.class); + String beanPropertyName = "name"; + + doReturn(beanPropertyName).when(selector).getBeanPropertyName(beanProperty); + + when(accessor.getDeclaringClass()).thenReturn((Class) Class.forName("io.beanmapper.annotations.model.bean_property.Person")); + + doReturn(true).when(selector).shadowsField(any(), any(), any()); + + doReturn(false).when(selector).hasBeanPropertyAnnotationAndOtherPropertyName(any(), any()); + + assertDoesNotThrow(() -> selector.detectBeanPropertyFieldShadowing(accessor, beanProperty)); + } + + @Test + void testDetectBeanPropertyFieldShadowingShouldNotDetectBeanPropertyFieldShadowingWhenNotShadowsField() throws ClassNotFoundException { + PropertyAccessor accessor = mock(PropertyAccessor.class); + BeanProperty beanProperty = mock(BeanProperty.class); + String beanPropertyName = "name"; + + doReturn(beanPropertyName).when(selector).getBeanPropertyName(beanProperty); + + when(accessor.getDeclaringClass()).thenReturn((Class) Class.forName("io.beanmapper.annotations.model.bean_property.Person")); + + doReturn(false).when(selector).shadowsField(any(), any(), any()); + + doReturn(true).when(selector).hasBeanPropertyAnnotationAndOtherPropertyName(any(), any()); + + assertDoesNotThrow(() -> selector.detectBeanPropertyFieldShadowing(accessor, beanProperty)); + } + + @Test + void testHasBeanPropertyAnnotationAndOtherPropertyName() { + Field field = mock(Field.class); + when(field.isAnnotationPresent(BeanProperty.class)).thenReturn(false); + + assertTrue(selector.hasBeanPropertyAnnotationAndOtherPropertyName(field, "test")); + } + + @Test + void testHasBeanPropertyAnnotationAndOtherPropertyNameShouldReturnTrueWhenHasBeanPropertyAndSamePropertyName() { + Field field = mock(Field.class); + BeanProperty beanProperty = mock(BeanProperty.class); + when(beanProperty.name()).thenReturn("test"); + when(field.isAnnotationPresent(BeanProperty.class)).thenReturn(true); + when(field.getName()).thenReturn("test"); + when(field.getAnnotation(BeanProperty.class)).thenReturn(beanProperty); + + assertTrue(selector.hasBeanPropertyAnnotationAndOtherPropertyName(field, "test")); + } + + @Test + void testShadowsFieldShouldReturnTrueWhenAllTrueAndDoesNotHaveBeanIgnored() throws IntrospectionException { + String name = "test"; + + Field field = mock(Field.class); + when(selector.isFieldAccessible(field)).thenReturn(true); + when(field.isAnnotationPresent(BeanIgnore.class)).thenReturn(false); + when(field.getName()).thenReturn(name); + + PropertyAccessor accessor = mock(PropertyAccessor.class); + + when(selector.isFieldNameNotEqualToAccessorName(accessor, field)).thenReturn(true); + assertTrue(selector.shadowsField(accessor, field, name)); + } + + @Test + void testShadowsFieldShouldReturnFalseWhenFieldInaccessible() throws IntrospectionException { + String name = "test"; + + Field field = mock(Field.class); + when(selector.isFieldAccessible(field)).thenReturn(false); + when(field.isAnnotationPresent(BeanIgnore.class)).thenReturn(true); + when(field.getName()).thenReturn(name); + + PropertyAccessor accessor = mock(PropertyAccessor.class); + + when(selector.isFieldNameNotEqualToAccessorName(accessor, field)).thenReturn(false); + assertFalse(selector.shadowsField(accessor, field, name)); + } + + @Test + void testShadowsFieldShouldReturnFalseWhenFieldNameNotEqualToBeanPropertyName() throws IntrospectionException { + String name = "test"; + + Field field = mock(Field.class); + when(selector.isFieldAccessible(field)).thenReturn(true); + when(field.isAnnotationPresent(BeanIgnore.class)).thenReturn(true); + when(field.getName()).thenReturn("differentName"); + + PropertyAccessor accessor = mock(PropertyAccessor.class); + + when(selector.isFieldNameNotEqualToAccessorName(accessor, field)).thenReturn(false); + assertFalse(selector.shadowsField(accessor, field, name)); + } + + @Test + void testShadowsFieldShouldThrowRuntimeExceptionWhenIntrospectionExceptionIsCaught() throws IntrospectionException { + Field field = mock(Field.class); + PropertyAccessor accessor = mock(PropertyAccessor.class); + when(field.getName()).thenReturn("test"); + when(accessor.getName()).thenReturn("test"); + doThrow(IntrospectionException.class).when(selector).isFieldAccessible(field); + assertThrows(RuntimeException.class, () -> selector.shadowsField(accessor, field, "")); + } + + @Test + void testShadowsFieldShouldReturnFalseWhenFieldNameIsEqualToAccessorName() throws IntrospectionException { + String name = "test"; + + Field field = mock(Field.class); + when(selector.isFieldAccessible(field)).thenReturn(true); + when(field.isAnnotationPresent(BeanIgnore.class)).thenReturn(true); + when(field.getName()).thenReturn("test"); + + PropertyAccessor accessor = mock(PropertyAccessor.class); + + when(selector.isFieldNameNotEqualToAccessorName(accessor, field)).thenReturn(false); + assertFalse(selector.shadowsField(accessor, field, name)); + } +} diff --git a/src/test/java/io/beanmapper/core/inspector/models/bean_property_selector/BeanPropertyImpl.java b/src/test/java/io/beanmapper/core/inspector/models/bean_property_selector/BeanPropertyImpl.java new file mode 100644 index 00000000..6d2131d8 --- /dev/null +++ b/src/test/java/io/beanmapper/core/inspector/models/bean_property_selector/BeanPropertyImpl.java @@ -0,0 +1,38 @@ +package io.beanmapper.core.inspector.models.bean_property_selector; + +import java.lang.annotation.Annotation; + +import io.beanmapper.annotations.BeanProperty; + +public class BeanPropertyImpl implements Annotation, BeanProperty { + + private final String name; + private final String value; + private final Class[] targets; + + public BeanPropertyImpl(String name, String value, Class[] targets) { + this.name = name; + this.value = value; + this.targets = targets; + } + + @Override + public String name() { + return this.name; + } + + @Override + public String value() { + return value; + } + + @Override + public Class[] targets() { + return targets; + } + + @Override + public Class annotationType() { + return BeanProperty.class; + } +} diff --git a/src/test/java/io/beanmapper/strategy/ConstructorArgumentsTest.java b/src/test/java/io/beanmapper/strategy/ConstructorArgumentsTest.java index 3fedac45..3ead5631 100644 --- a/src/test/java/io/beanmapper/strategy/ConstructorArgumentsTest.java +++ b/src/test/java/io/beanmapper/strategy/ConstructorArgumentsTest.java @@ -1,10 +1,11 @@ package io.beanmapper.strategy; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; import io.beanmapper.config.BeanPair; import io.beanmapper.core.BeanMatch; import io.beanmapper.core.BeanMatchStore; +import io.beanmapper.core.inspector.BeanPropertySelector; import io.beanmapper.testmodel.person.PersonForm; import io.beanmapper.testmodel.person.PersonResult; @@ -21,7 +22,7 @@ class ConstructorArgumentsTest { @BeforeEach void setUp() { this.beanPair = new BeanPair(PersonForm.class, PersonResult.class); - this.beanMatchStore = new BeanMatchStore(null, null); + this.beanMatchStore = new BeanMatchStore(null, null, new BeanPropertySelector()); this.beanMatch = this.beanMatchStore.getBeanMatch(this.beanPair); this.personForm = new PersonForm(); this.personForm.setName("Henk"); @@ -32,4 +33,4 @@ void setUp() { void testPassNullAsConstructorArgs_ThrowsIllegalArgumentException() { assertThrows(IllegalArgumentException.class, () -> new ConstructorArguments(this.personForm, this.beanMatch, null)); } -} \ No newline at end of file +}