Skip to content

Commit

Permalink
Make BeanProperty repeatable
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
marcus-talbot42 committed Jul 19, 2024
1 parent e326fa8 commit 74e2a58
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 37 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/io/beanmapper/annotations/BeanProperty.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,6 +10,7 @@
* 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 {
Expand All @@ -17,4 +19,18 @@

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();
}
}
117 changes: 87 additions & 30 deletions src/main/java/io/beanmapper/core/BeanMatchStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import io.beanmapper.core.unproxy.BeanUnproxy;
import io.beanmapper.exceptions.BeanMissingPathException;
import io.beanmapper.exceptions.BeanNoSuchPropertyException;
import io.beanmapper.exceptions.DuplicateBeanPropertyTargetException;
import io.beanmapper.exceptions.FieldShadowingException;
import io.beanmapper.utils.BeanMapperTraceLogger;
import io.beanmapper.utils.Trinary;
Expand Down Expand Up @@ -94,7 +95,7 @@ public BeanMatch addBeanMatch(BeanMatch beanMatch) {
private BeanMatch determineBeanMatch(BeanPair beanPair) {

Map<String, BeanProperty> sourceNode = new HashMap<>();
Map<String, BeanProperty>targetNode = new HashMap<>();
Map<String, BeanProperty> targetNode = new HashMap<>();
Map<String, BeanProperty> aliases = new HashMap<>();

return new BeanMatch(
Expand All @@ -106,16 +107,19 @@ private BeanMatch determineBeanMatch(BeanPair beanPair) {
beanPair.getSourceClass(),
beanPair.getTargetClass(),
null,
BeanPropertyMatchupDirection.SOURCE_TO_TARGET),
BeanPropertyMatchupDirection.SOURCE_TO_TARGET
),
getAllFields(
targetNode,
sourceNode,
aliases,
beanPair.getTargetClass(),
beanPair.getSourceClass(),
null,
BeanPropertyMatchupDirection.TARGET_TO_SOURCE),
aliases);
BeanPropertyMatchupDirection.TARGET_TO_SOURCE
),
aliases
);
}

private Map<String, BeanProperty> getAllFields(Map<String, BeanProperty> ourNodes, Map<String, BeanProperty> otherNodes, Map<String, BeanProperty> aliases,
Expand All @@ -126,12 +130,7 @@ private Map<String, BeanProperty> getAllFields(Map<String, BeanProperty> 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;
}

Expand All @@ -157,15 +156,18 @@ private Map<String, BeanProperty> getAllFields(Map<String, BeanProperty> ourNode
handleBeanCollectionAnnotation(
accessor.findAnnotation(BeanCollection.class),
currentBeanProperty,
matchupDirection);
matchupDirection
);

handleBeanRoleSecuredAnnotation(
currentBeanProperty,
accessor.findAnnotation(BeanRoleSecured.class));
accessor.findAnnotation(BeanRoleSecured.class)
);

handleBeanLogicSecuredAnnotation(
currentBeanProperty,
accessor.findAnnotation(BeanLogicSecured.class));
accessor.findAnnotation(BeanLogicSecured.class)
);

if (accessor.isAnnotationPresent(BeanAlias.class)) {
BeanAlias beanAlias = accessor.findAnnotation(BeanAlias.class);
Expand All @@ -183,7 +185,8 @@ private Map<String, BeanProperty> getAllFields(Map<String, BeanProperty> ourNode
accessor.getType(),
otherType,
currentBeanProperty,
matchupDirection);
matchupDirection
);
} else {
ourCurrentNodes.put(beanPropertyWrapper.getName(), currentBeanProperty);
}
Expand Down Expand Up @@ -260,8 +263,20 @@ private CollectionHandler getCollectionHandlerFor(BeanProperty beanProperty) {
private BeanPropertyWrapper dealWithBeanProperty(BeanPropertyMatchupDirection matchupDirection, Map<String, BeanProperty> 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);
if (accessor.isAnnotationPresent(io.beanmapper.annotations.BeanProperty.BeanProperties.class) || accessor.isAnnotationPresent(
io.beanmapper.annotations.BeanProperty.class)) {
io.beanmapper.annotations.BeanProperty beanProperty;
if (accessor.isAnnotationPresent(io.beanmapper.annotations.BeanProperty.BeanProperties.class)) {
io.beanmapper.annotations.BeanProperty.BeanProperties beanProperties = accessor.findAnnotation(
io.beanmapper.annotations.BeanProperty.BeanProperties.class);

beanProperty = determineRelevantBeanPropertyForBeanMatch(beanProperties, otherType);
} else {
beanProperty = accessor.findAnnotation(io.beanmapper.annotations.BeanProperty.class);
if (Arrays.stream(beanProperty.targets()).noneMatch(clazz -> clazz == otherType || clazz == Void.class)) {
return wrapper;
}
}

detectBeanPropertyFieldShadowing(accessor, beanProperty);

Expand All @@ -273,27 +288,67 @@ private BeanPropertyWrapper dealWithBeanProperty(BeanPropertyMatchupDirection ma
otherNodes.put(
wrapper.getName(),
new BeanPropertyCreator(matchupDirection.getInverse(), otherType, wrapper.getName())
.determineNodesForPath());
.determineNodesForPath()
);
} catch (BeanNoSuchPropertyException err) {
BeanMapperTraceLogger.log("""
BeanNoSuchPropertyException thrown by BeanMatchStore#dealWithBeanProperty(BeanPropertyMatchupDirection, Map<String, BeanProperty>, Class, PropertyAccessor), for {}.
{}""", wrapper.getName(), err.getMessage());
BeanMapperTraceLogger.log("""
BeanNoSuchPropertyException thrown by BeanMatchStore#dealWithBeanProperty(BeanPropertyMatchupDirection, Map<String, BeanProperty>, Class, PropertyAccessor), for {}.
{}""", wrapper.getName(), err.getMessage());
}
}
return wrapper;
}

/**
* 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.
* <p>
* 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.
* </p>
*
* @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.
*/
private io.beanmapper.annotations.BeanProperty determineRelevantBeanPropertyForBeanMatch(
io.beanmapper.annotations.BeanProperty.BeanProperties beanProperties, Class<?> otherType) {
List<io.beanmapper.annotations.BeanProperty> relevantBeanProperties = Arrays.stream(beanProperties.value())
.filter(beanProperty -> Arrays.stream(beanProperty.targets()).anyMatch(
pairedBean -> pairedBean == otherType))
.toList();
if (relevantBeanProperties.size() > 1) {
throw new DuplicateBeanPropertyTargetException("(Target: %s)".formatted(otherType.getSimpleName()));
}
if (relevantBeanProperties.isEmpty()) {
var wildCardBeanProperties = Arrays.stream(beanProperties.value())
.filter(beanProperty -> Arrays.stream(beanProperty.targets())
.anyMatch(clazz -> clazz == Void.class))
.toList();
if (wildCardBeanProperties.isEmpty()) {
return null;
}
if (wildCardBeanProperties.size() > 1) {
throw new DuplicateBeanPropertyTargetException(
"(Target: any; Only one BeanProperty may have Void.class (the wildcard-type for BeanProperties) used in its targets-property.)");
}
return wildCardBeanProperties.get(0);
}
return relevantBeanProperties.get(0);
}

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.
*
* <p>
* 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 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.
Expand All @@ -306,20 +361,22 @@ private void detectBeanPropertyFieldShadowing(final PropertyAccessor accessor, f
for (var field : fields) {
var fieldName = field.getName();
if (!fieldName.equals(accessor.getName())
&& !field.isAnnotationPresent(BeanIgnore.class)
&& fieldName.equals(beanPropertyName)
&& (Modifier.isPublic(field.getModifiers())
&& !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));
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));
field.getDeclaringClass().getName(), fieldName
));
}
}
}
Expand All @@ -331,9 +388,9 @@ private void detectBeanPropertyFieldShadowing(final PropertyAccessor accessor, f
* <p>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.</p>
*
* <p>
* In this context, an accessible mutator-method is any method which is public and adheres to the Java
* Beans definition of a mutator.
* 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.
Expand All @@ -342,7 +399,7 @@ private void detectBeanPropertyFieldShadowing(final PropertyAccessor accessor, f
*/
private boolean hasAccessibleWriteMethod(final Field field) throws IntrospectionException {
return Arrays.stream(Introspector.getBeanInfo(field.getDeclaringClass()).getPropertyDescriptors())
.anyMatch(propertyDescriptor -> propertyDescriptor.getName().equals(field.getName()));
.anyMatch(propertyDescriptor -> propertyDescriptor.getName().equals(field.getName()));
}

}
Original file line number Diff line number Diff line change
@@ -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));
}
}
66 changes: 66 additions & 0 deletions src/test/java/io/beanmapper/annotations/BeanPropertyTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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.config.BeanMapperBuilder;
import io.beanmapper.exceptions.DuplicateBeanPropertyTargetException;

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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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("lastName")
@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;
}
}
Loading

0 comments on commit 74e2a58

Please sign in to comment.