From ee794d042cecff4a89e30adf42ef6fc07211c453 Mon Sep 17 00:00:00 2001 From: Sjaak Derksen Date: Sat, 7 Dec 2019 22:20:11 +0100 Subject: [PATCH] #807 meta annotations and duck typing (#1979) --- core/src/main/java/org/mapstruct/Mapping.java | 2 +- .../src/main/java/org/mapstruct/Mappings.java | 2 +- .../chapter-3-defining-a-mapper.asciidoc | 44 ++++++++++++ .../ap/internal/model/source/Mapping.java | 40 +++++------ .../processor/MethodRetrievalProcessor.java | 60 ++++++++++++---- .../ap/test/mappingcomposition/BoxDto.java | 37 ++++++++++ .../ap/test/mappingcomposition/BoxEntity.java | 57 +++++++++++++++ .../mappingcomposition/CompositionTest.java | 71 +++++++++++++++++++ .../ap/test/mappingcomposition/ShelveDto.java | 46 ++++++++++++ .../test/mappingcomposition/ShelveEntity.java | 67 +++++++++++++++++ .../mappingcomposition/StorageMapper.java | 24 +++++++ .../ap/test/mappingcomposition/ToEntity.java | 17 +++++ 12 files changed, 430 insertions(+), 37 deletions(-) create mode 100644 processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxDto.java create mode 100644 processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxEntity.java create mode 100644 processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/CompositionTest.java create mode 100644 processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveDto.java create mode 100644 processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveEntity.java create mode 100644 processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/StorageMapper.java create mode 100644 processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ToEntity.java diff --git a/core/src/main/java/org/mapstruct/Mapping.java b/core/src/main/java/org/mapstruct/Mapping.java index 92bf5742e..98a309e05 100644 --- a/core/src/main/java/org/mapstruct/Mapping.java +++ b/core/src/main/java/org/mapstruct/Mapping.java @@ -142,7 +142,7 @@ import static org.mapstruct.NullValueCheckStrategy.ON_IMPLICIT_CONVERSION; @Repeatable(Mappings.class) @Retention(RetentionPolicy.CLASS) -@Target(ElementType.METHOD) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) public @interface Mapping { /** diff --git a/core/src/main/java/org/mapstruct/Mappings.java b/core/src/main/java/org/mapstruct/Mappings.java index 37e33771d..039ec7b3c 100644 --- a/core/src/main/java/org/mapstruct/Mappings.java +++ b/core/src/main/java/org/mapstruct/Mappings.java @@ -42,7 +42,7 @@ import java.lang.annotation.Target; * @author Gunnar Morling */ @Retention(RetentionPolicy.CLASS) -@Target(ElementType.METHOD) +@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE }) public @interface Mappings { /** diff --git a/documentation/src/main/asciidoc/chapter-3-defining-a-mapper.asciidoc b/documentation/src/main/asciidoc/chapter-3-defining-a-mapper.asciidoc index 347750ecc..335bbbf95 100644 --- a/documentation/src/main/asciidoc/chapter-3-defining-a-mapper.asciidoc +++ b/documentation/src/main/asciidoc/chapter-3-defining-a-mapper.asciidoc @@ -124,6 +124,50 @@ Collection-typed attributes with the same element type will be copied by creatin MapStruct takes all public properties of the source and target types into account. This includes properties declared on super-types. +[[mapping-composition]] +=== Mapping Composition (experimental) +MapStruct supports the use of meta annotations. The `@Mapping` annotation supports now `@Target` with `ElementType#ANNOTATION_TYPE` in addition to `ElementType#METHOD`. This allows `@Mapping` to be used on other (user defined) annotations for re-use purposes. For example: + +==== +[source, java, linenums] +[subs="verbatim,attributes"] +---- +@Retention(RetentionPolicy.CLASS) +@Mapping(target = "id", ignore = true) +@Mapping(target = "creationDate", expression = "java(new java.util.Date())") +@Mapping(target = "name", source = "groupName") +public @interface ToEntity { } +---- +==== + +Can be used to characterise an `Entity` without the need to have a common base type. For instance, `ShelveEntity` and `BoxEntity` do not share a common base type in the `StorageMapper` below. +==== +[source, java, linenums] +[subs="verbatim,attributes"] +---- +@Mapper +public interface StorageMapper { + + StorageMapper INSTANCE = Mappers.getMapper( StorageMapper.class ); + + @ToEntity + @Mapping( target = "weightLimit", source = "maxWeight") + ShelveEntity map(ShelveDto source); + + @ToEntity + @Mapping( target = "label", source = "designation") + BoxEntity map(BoxDto source); +} +---- +==== + +Still, they do have some properties in common. The `@ToEntity` assumes both target beans `ShelveEntity` and `BoxEntity` have properties: `"id"`, `"creationDate"` and `"name"`. It furthermore assumes that the source beans `ShelveDto` and `BoxDto` always have a property `"groupName"`. This concept is also known as "duck-typing". In other words, if it quacks like duck, walks like a duck its probably a duck. + +This feature is still experimental. Error messages are not mature yet: the method on which the problem occurs is displayed, as well as the concerned values in the `@Mapping` annotation. However, the composition aspect is not visible. The messages are "as if" the `@Mapping` would be present on the concerned method directly. +Therefore, the user should use this feature with care, especially when uncertain when a property is always present. + +A more typesafe (but also more verbose) way would be to define base classes / interfaces on the target bean and the source bean and use `@InheritConfiguration` to achieve the same result (see <>). + [[adding-custom-methods]] === Adding custom methods to mappers diff --git a/processor/src/main/java/org/mapstruct/ap/internal/model/source/Mapping.java b/processor/src/main/java/org/mapstruct/ap/internal/model/source/Mapping.java index 00e6d9bef..21ece8e8f 100644 --- a/processor/src/main/java/org/mapstruct/ap/internal/model/source/Mapping.java +++ b/processor/src/main/java/org/mapstruct/ap/internal/model/source/Mapping.java @@ -92,36 +92,25 @@ public class Mapping { return mappings.stream().filter( mapping -> mapping.targetName.equals( targetName ) ).findAny().orElse( null ); } - public static Set fromMappingsPrism(MappingsPrism mappingsAnnotation, ExecutableElement method, - FormattingMessager messager, Types typeUtils) { - Set mappings = new LinkedHashSet<>(); + public static void addFromMappingsPrism(MappingsPrism mappingsAnnotation, ExecutableElement method, + FormattingMessager messager, Types typeUtils, Set mappings) { for ( MappingPrism mappingPrism : mappingsAnnotation.value() ) { - Mapping mapping = fromMappingPrism( mappingPrism, method, messager, typeUtils ); - if ( mapping != null ) { - if ( mappings.contains( mapping ) ) { - messager.printMessage( method, Message.PROPERTYMAPPING_DUPLICATE_TARGETS, mappingPrism.target() ); - } - else { - mappings.add( mapping ); - } - } + addFromMappingPrism( mappingPrism, method, messager, typeUtils, mappings ); } - - return mappings; } - public static Mapping fromMappingPrism(MappingPrism mappingPrism, ExecutableElement element, - FormattingMessager messager, Types typeUtils) { + public static void addFromMappingPrism(MappingPrism mappingPrism, ExecutableElement method, + FormattingMessager messager, Types typeUtils, Set mappings) { - if (!isConsistent( mappingPrism, element, messager ) ) { - return null; + if (!isConsistent( mappingPrism, method, messager ) ) { + return; } String source = mappingPrism.source().isEmpty() ? null : mappingPrism.source(); String constant = mappingPrism.values.constant() == null ? null : mappingPrism.constant(); - String expression = getExpression( mappingPrism, element, messager ); - String defaultExpression = getDefaultExpression( mappingPrism, element, messager ); + String expression = getExpression( mappingPrism, method, messager ); + String defaultExpression = getDefaultExpression( mappingPrism, method, messager ); String dateFormat = mappingPrism.values.dateFormat() == null ? null : mappingPrism.dateFormat(); String numberFormat = mappingPrism.values.numberFormat() == null ? null : mappingPrism.numberFormat(); String defaultValue = mappingPrism.values.defaultValue() == null ? null : mappingPrism.defaultValue(); @@ -136,7 +125,7 @@ public class Mapping { numberFormat, mappingPrism.mirror, mappingPrism.values.dateFormat(), - element + method ); SelectionParameters selectionParams = new SelectionParameters( mappingPrism.qualifiedBy(), @@ -155,7 +144,7 @@ public class Mapping { ? null : NullValuePropertyMappingStrategyPrism.valueOf( mappingPrism.nullValuePropertyMappingStrategy() ); - return new Mapping( + Mapping mapping = new Mapping( source, constant, expression, @@ -174,6 +163,13 @@ public class Mapping { nullValuePropertyMappingStrategy, null ); + + if ( mappings.contains( mapping ) ) { + messager.printMessage( method, Message.PROPERTYMAPPING_DUPLICATE_TARGETS, mappingPrism.target() ); + } + else { + mappings.add( mapping ); + } } public static Mapping forIgnore( String targetName) { diff --git a/processor/src/main/java/org/mapstruct/ap/internal/processor/MethodRetrievalProcessor.java b/processor/src/main/java/org/mapstruct/ap/internal/processor/MethodRetrievalProcessor.java index cc1b0c350..786ec0a12 100644 --- a/processor/src/main/java/org/mapstruct/ap/internal/processor/MethodRetrievalProcessor.java +++ b/processor/src/main/java/org/mapstruct/ap/internal/processor/MethodRetrievalProcessor.java @@ -11,6 +11,9 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.Modifier; import javax.lang.model.element.TypeElement; @@ -57,6 +60,11 @@ import static org.mapstruct.ap.internal.util.Executables.getAllEnclosedExecutabl */ public class MethodRetrievalProcessor implements ModelElementProcessor> { + private static final String JAVA_LANG_ANNOTATION_PGK = "java.lang.annotation"; + private static final String ORG_MAPSTRUCT_PKG = "org.mapstruct"; + private static final String MAPPING_FQN = "org.mapstruct.Mapping"; + private static final String MAPPINGS_FQN = "org.mapstruct.Mappings"; + private FormattingMessager messager; private TypeFactory typeFactory; private AccessorNamingUtils accessorNaming; @@ -243,7 +251,7 @@ public class MethodRetrievalProcessor implements ModelElementProcessor(), new HashSet<>() ) ) .setIterableMapping( IterableMapping.fromPrism( IterableMappingPrism.getInstanceOn( method ), @@ -492,26 +500,52 @@ public class MethodRetrievalProcessor implements ModelElementProcessor getMappings(ExecutableElement method) { - Set mappings = new LinkedHashSet<>( ); + private Set getMappings(ExecutableElement method, Element element, Set mappings, + Set handledElements) { - MappingPrism mappingAnnotation = MappingPrism.getInstanceOn( method ); - MappingsPrism mappingsAnnotation = MappingsPrism.getInstanceOn( method ); - - if ( mappingAnnotation != null ) { - mappings.add( Mapping.fromMappingPrism( mappingAnnotation, method, messager, typeUtils ) ); + for ( AnnotationMirror annotationMirror : element.getAnnotationMirrors() ) { + Element lElement = annotationMirror.getAnnotationType().asElement(); + if ( isAnnotation( lElement, MAPPING_FQN ) ) { + // although getInstanceOn does a search on annotation mirrors, the order is preserved + MappingPrism mappingAnnotation = MappingPrism.getInstanceOn( element ); + Mapping.addFromMappingPrism( mappingAnnotation, method, messager, typeUtils, mappings ); + } + else if ( isAnnotation( lElement, MAPPINGS_FQN ) ) { + // although getInstanceOn does a search on annotation mirrors, the order is preserved + MappingsPrism mappingsAnnotation = MappingsPrism.getInstanceOn( element ); + Mapping.addFromMappingsPrism( mappingsAnnotation, method, messager, typeUtils, mappings ); + } + else if ( !isAnnotationInPackage( lElement, JAVA_LANG_ANNOTATION_PGK ) + && !isAnnotationInPackage( lElement, ORG_MAPSTRUCT_PKG ) + && !handledElements.contains( lElement ) + ) { + // recur over annotation mirrors + handledElements.add( lElement ); + getMappings( method, lElement, mappings, handledElements ); + } } - - if ( mappingsAnnotation != null ) { - mappings.addAll( Mapping.fromMappingsPrism( mappingsAnnotation, method, messager, typeUtils ) ); - } - return mappings; } + private boolean isAnnotationInPackage(Element element, String packageFQN ) { + if ( ElementKind.ANNOTATION_TYPE == element.getKind() ) { + return packageFQN.equals( elementUtils.getPackageOf( element ).getQualifiedName().toString() ); + } + return false; + } + + private boolean isAnnotation(Element element, String annotationFQN) { + if ( ElementKind.ANNOTATION_TYPE == element.getKind() ) { + return annotationFQN.equals( ( (TypeElement) element ).getQualifiedName().toString() ); + } + return false; + } + /** * Retrieves the mappings configured via {@code @ValueMapping} from the given * method. diff --git a/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxDto.java b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxDto.java new file mode 100644 index 000000000..1e984b792 --- /dev/null +++ b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxDto.java @@ -0,0 +1,37 @@ +/* + * Copyright MapStruct Authors. + * + * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.mapstruct.ap.test.mappingcomposition; + +public class BoxDto { + + private String groupName; + private String designation; + private ShelveDto shelve; + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public String getDesignation() { + return designation; + } + + public void setDesignation(String designation) { + this.designation = designation; + } + + public ShelveDto getShelve() { + return shelve; + } + + public void setShelve(ShelveDto shelve) { + this.shelve = shelve; + } +} diff --git a/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxEntity.java b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxEntity.java new file mode 100644 index 000000000..d0baa7c9d --- /dev/null +++ b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/BoxEntity.java @@ -0,0 +1,57 @@ +/* + * Copyright MapStruct Authors. + * + * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.mapstruct.ap.test.mappingcomposition; + +import java.util.Date; + +public class BoxEntity { + + private Date creationDate; + private Long id; + private String name; + private String label; + private ShelveEntity shelve; + + public Date getCreationDate() { + return creationDate; + } + + public void setCreationDate(Date creationDate) { + this.creationDate = creationDate; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public ShelveEntity getShelve() { + return shelve; + } + + public void setShelve(ShelveEntity shelve) { + this.shelve = shelve; + } +} diff --git a/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/CompositionTest.java b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/CompositionTest.java new file mode 100644 index 000000000..6850c6f4e --- /dev/null +++ b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/CompositionTest.java @@ -0,0 +1,71 @@ +/* + * Copyright MapStruct Authors. + * + * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.mapstruct.ap.test.mappingcomposition; + +import java.util.Date; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mapstruct.ap.testutil.IssueKey; +import org.mapstruct.ap.testutil.WithClasses; +import org.mapstruct.ap.testutil.runner.AnnotationProcessorTestRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * + * @author Sjaak Derksen + */ +@IssueKey("807") +@WithClasses({ + BoxDto.class, + BoxEntity.class, + ShelveDto.class, + ShelveEntity.class, + StorageMapper.class, + ToEntity.class +}) +@RunWith(AnnotationProcessorTestRunner.class) +public class CompositionTest { + + @Test + public void shouldCompose() { + + Date now = new Date(); + Date anHourAgo = new Date( now.getTime() - 3600 * 1000 ); + Date anHourFromNow = new Date( now.getTime() + 3600 * 1000 ); + + ShelveDto shelve = new ShelveDto(); + shelve.setGroupName( "blue things" ); + shelve.setMaxWeight( 10.0d ); + shelve.setPath( "row5" ); + shelve.setStandNumber( 3 ); + + BoxDto box = new BoxDto(); + box.setDesignation( "blue item" ); + box.setGroupName( "blue stuff" ); + box.setShelve( shelve ); + + BoxEntity boxEntity = StorageMapper.INSTANCE.map( box ); + + // box + assertThat( boxEntity ).isNotNull(); + assertThat( boxEntity.getCreationDate() ).isBetween( anHourAgo, anHourFromNow ); + assertThat( boxEntity.getId() ).isNull(); + assertThat( boxEntity.getName() ).isEqualTo( "blue stuff" ); + assertThat( boxEntity.getLabel() ).isEqualTo( "blue item" ); + + // shelve + ShelveEntity shelveEntity = boxEntity.getShelve(); + assertThat( shelveEntity.getCreationDate() ).isBetween( anHourAgo, anHourFromNow ); + assertThat( shelveEntity.getId() ).isNull(); + assertThat( shelveEntity.getName() ).isEqualTo( "blue things" ); + assertThat( shelveEntity.getPath() ).isEqualTo( "row5" ); + assertThat( shelveEntity.getStandNumber() ).isEqualTo( 3 ); + assertThat( shelveEntity.getWeightLimit() ).isEqualTo( 10.0d ); + + } +} diff --git a/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveDto.java b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveDto.java new file mode 100644 index 000000000..37cfe7903 --- /dev/null +++ b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveDto.java @@ -0,0 +1,46 @@ +/* + * Copyright MapStruct Authors. + * + * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.mapstruct.ap.test.mappingcomposition; + +public class ShelveDto { + + private String groupName; + private String path; + private int standNumber; + private double maxWeight; + + public String getGroupName() { + return groupName; + } + + public void setGroupName(String groupName) { + this.groupName = groupName; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public int getStandNumber() { + return standNumber; + } + + public void setStandNumber(int standNumber) { + this.standNumber = standNumber; + } + + public double getMaxWeight() { + return maxWeight; + } + + public void setMaxWeight(double maxWeight) { + this.maxWeight = maxWeight; + } +} diff --git a/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveEntity.java b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveEntity.java new file mode 100644 index 000000000..39a397c7c --- /dev/null +++ b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ShelveEntity.java @@ -0,0 +1,67 @@ +/* + * Copyright MapStruct Authors. + * + * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.mapstruct.ap.test.mappingcomposition; + +import java.util.Date; + +public class ShelveEntity { + + private Date creationDate; + private Long id; + private String name; + + private String path; + private int standNumber; + private double weightLimit; + + public Date getCreationDate() { + return creationDate; + } + + public void setCreationDate(Date creationDate) { + this.creationDate = creationDate; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public int getStandNumber() { + return standNumber; + } + + public void setStandNumber(int standNumber) { + this.standNumber = standNumber; + } + + public double getWeightLimit() { + return weightLimit; + } + + public void setWeightLimit(double weightLimit) { + this.weightLimit = weightLimit; + } +} diff --git a/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/StorageMapper.java b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/StorageMapper.java new file mode 100644 index 000000000..513696801 --- /dev/null +++ b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/StorageMapper.java @@ -0,0 +1,24 @@ +/* + * Copyright MapStruct Authors. + * + * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.mapstruct.ap.test.mappingcomposition; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.factory.Mappers; + +@Mapper +public interface StorageMapper { + + StorageMapper INSTANCE = Mappers.getMapper( StorageMapper.class ); + + @ToEntity + @Mapping( target = "weightLimit", source = "maxWeight") + ShelveEntity map(ShelveDto source); + + @ToEntity + @Mapping( target = "label", source = "designation") + BoxEntity map(BoxDto source); +} diff --git a/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ToEntity.java b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ToEntity.java new file mode 100644 index 000000000..4cbd94c66 --- /dev/null +++ b/processor/src/test/java/org/mapstruct/ap/test/mappingcomposition/ToEntity.java @@ -0,0 +1,17 @@ +/* + * Copyright MapStruct Authors. + * + * Licensed under the Apache License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.mapstruct.ap.test.mappingcomposition; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.mapstruct.Mapping; + +@Retention(RetentionPolicy.CLASS) +@Mapping(target = "id", ignore = true) +@Mapping(target = "creationDate", expression = "java(new java.util.Date())") +@Mapping(target = "name", source = "groupName") +public @interface ToEntity { }