diff --git a/context/src/main/java/io/micronaut/runtime/beans/MapperIntroduction.java b/context/src/main/java/io/micronaut/runtime/beans/MapperIntroduction.java index cf8e94477be..8491de74729 100644 --- a/context/src/main/java/io/micronaut/runtime/beans/MapperIntroduction.java +++ b/context/src/main/java/io/micronaut/runtime/beans/MapperIntroduction.java @@ -25,8 +25,10 @@ import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; import io.micronaut.core.beans.BeanIntrospection; import io.micronaut.core.beans.BeanProperty; +import io.micronaut.core.beans.exceptions.IntrospectionException; import io.micronaut.core.convert.ArgumentConversionContext; import io.micronaut.core.convert.ConversionContext; import io.micronaut.core.convert.ConversionService; @@ -41,6 +43,8 @@ import io.micronaut.inject.annotation.EvaluatedAnnotationMetadata; import io.micronaut.inject.annotation.MutableAnnotationMetadata; +import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -97,11 +101,18 @@ public Object intercept(MethodInvocationContext context) { isMap ); + List>>> rootMappers = buildRootMappers( + fromIntrospection, + conflictStrategy, + annotations, + isMap + ); + // requires runtime evaluation - if (!customMappers.isEmpty()) { + if (!customMappers.isEmpty() || rootMappers != null) { if (isMap) { invocation = callContext -> { - MapStrategy mapStrategy = buildMapStrategy(conflictStrategy, customMappers, callContext); + MapStrategy mapStrategy = buildMapStrategy(conflictStrategy, customMappers, rootMappers, callContext); return map( (Map) callContext.getParameterValues()[0], mapStrategy, @@ -110,7 +121,7 @@ public Object intercept(MethodInvocationContext context) { }; } else { invocation = callContext -> { - MapStrategy mapStrategy = buildMapStrategy(conflictStrategy, customMappers, callContext); + MapStrategy mapStrategy = buildMapStrategy(conflictStrategy, customMappers, rootMappers, callContext); return map( callContext.getParameterValues()[0], mapStrategy, @@ -137,14 +148,123 @@ public Object intercept(MethodInvocationContext context) { } } - private static MapStrategy buildMapStrategy(Mapper.ConflictStrategy conflictStrategy, Map>>> customMappers, MethodInvocationContext callContext) { + private @Nullable List>>> buildRootMappers( + BeanIntrospection fromIntrospection, + Mapper.ConflictStrategy conflictStrategy, + List> annotations, + boolean isMap) { + List>>> rootMappers = new ArrayList<>(5); + for (AnnotationValue annotation : annotations) { + // a root mapping contains no object to bind to so we assume we bind to the root + if (!annotation.contains(Mapper.Mapping.MEMBER_TO) && annotation.contains(Mapper.Mapping.MEMBER_FROM)) { + Map values = annotation.getValues(); + Object from = values.get(Mapper.Mapping.MEMBER_FROM); + Object condition = values.get(Mapper.Mapping.MEMBER_CONDITION); + EvaluatedExpression evaluatedCondition = condition instanceof EvaluatedExpression ee ? ee : null; + + if (from instanceof EvaluatedExpression evaluatedExpression) { + if (evaluatedCondition != null) { + rootMappers.add(expressionEvaluationContext -> + (object, builder) -> { + ExpressionEvaluationContext evaluationContext = (ExpressionEvaluationContext) expressionEvaluationContext; + if (ObjectUtils.coerceToBoolean(evaluatedCondition.evaluate(evaluationContext))) { + Object v = evaluatedExpression.evaluate(evaluationContext); + if (v != null) { + mapAllFromValue(conflictStrategy, builder, v); + } + } + } + ); + } else { + rootMappers.add((expressionEvaluationContext -> + (object, builder) -> { + ExpressionEvaluationContext evaluationContext = (ExpressionEvaluationContext) expressionEvaluationContext; + Object v = evaluatedExpression.evaluate(evaluationContext); + if (v != null) { + mapAllFromValue(conflictStrategy, builder, v); + } + } + )); + } + } else if (from != null) { + String propertyName = from.toString(); + if (fromIntrospection != null) { + BeanProperty fromProperty = fromIntrospection.getRequiredProperty(propertyName, Object.class); + rootMappers.add((expressionEvaluationContext -> (object, builder) -> { + Object result = fromProperty.get(object); + if (result != null) { + mapAllFromValue(conflictStrategy, builder, result); + } + })); + } else if (isMap) { + rootMappers.add((expressionEvaluationContext -> (object, builder) -> { + Object result = ((Map) object).get(propertyName); + if (result != null) { + mapAllFromValue(conflictStrategy, builder, result); + } + })); + } + } + } + } + if (rootMappers.isEmpty()) { + return null; + } else { + return Collections.unmodifiableList(rootMappers); + } + } + + private void mapAllFromValue(Mapper.ConflictStrategy conflictStrategy, BeanIntrospection.Builder builder, Object object) { + BeanIntrospection nestedFrom; + try { + //noinspection unchecked + nestedFrom = (BeanIntrospection) BeanIntrospection.getIntrospection(object.getClass()); + } catch (IntrospectionException e) { + throw new IllegalArgumentException("Invalid @Mapping(from=..) declaration. The source property must declared @Introspected: " + e.getMessage(), e); + } + @NonNull Collection> propertyNames = nestedFrom.getBeanProperties(); + for (BeanProperty property : propertyNames) { + if (property.isWriteOnly()) { + continue; + } + int i = builder.indexOf(property.getName()); + if (i > -1) { + @SuppressWarnings("unchecked") + Argument argument = (Argument) builder.getBuilderArguments()[i]; + Object propertyValue = property.get(object); + if (argument.isInstance(propertyValue)) { + builder.with(i, argument, propertyValue); + } else if (conflictStrategy == Mapper.ConflictStrategy.CONVERT) { + builder.convert(i, ConversionContext.of(argument), propertyValue, conversionService); + } else { + throw new IllegalArgumentException("Cannot map invalid value [" + propertyValue + "] to type: " + argument); + } + } + } + } + + private static MapStrategy buildMapStrategy( + Mapper.ConflictStrategy conflictStrategy, + Map>>> customMappers, + @Nullable List>>> rootMappers, + MethodInvocationContext callContext) { MapStrategy mapStrategy = new MapStrategy(conflictStrategy); AnnotationMetadata callAnnotationMetadata = callContext.getAnnotationMetadata(); if (callAnnotationMetadata instanceof EvaluatedAnnotationMetadata evaluatedAnnotationMetadata) { ConfigurableExpressionEvaluationContext evaluationContext = evaluatedAnnotationMetadata.getEvaluationContext(); customMappers.forEach((name, mapperSupplier) -> mapStrategy.customMappers.put(name, mapperSupplier.apply(evaluationContext))); + if (rootMappers != null) { + for (Function>> mapSupplier : rootMappers) { + mapStrategy.rootMappers.add(mapSupplier.apply(evaluationContext)); + } + } } else { customMappers.forEach((name, mapperSupplier) -> mapStrategy.customMappers.put(name, mapperSupplier.apply(null))); + if (rootMappers != null) { + for (Function>> mapSupplier : rootMappers) { + mapStrategy.rootMappers.add(mapSupplier.apply(null)); + } + } } return mapStrategy; @@ -157,10 +277,11 @@ private Map> annotations, boolean isMap) { Map>>> customMappers = new HashMap<>(); + BeanIntrospection.Builder builderMeta = toIntrospection.builder(); + @NonNull Argument[] builderArguments = builderMeta.getBuilderArguments(); for (AnnotationValue mapping : annotations) { String to = mapping.stringValue(Mapper.Mapping.MEMBER_TO).orElse(null); String format = mapping.stringValue(Mapper.Mapping.MEMBER_FORMAT).orElse(null); - BeanIntrospection.Builder builderMeta = toIntrospection.builder(); if (StringUtils.isNotEmpty(to)) { @@ -168,7 +289,7 @@ private Map argument = (Argument) builderMeta.getBuilderArguments()[i]; + @SuppressWarnings("unchecked") Argument argument = (Argument) builderArguments[i]; ArgumentConversionContext conversionContext = null; if (format != null) { conversionContext = ConversionContext.of(argument); @@ -276,7 +397,7 @@ private O map(I input, MapStrategy mapStrategy, BeanIntrospection inp @SuppressWarnings("unchecked") @NonNull Argument[] arguments = (Argument[]) builder.getBuilderArguments(); if (!isDefault) { - processCustomMappers(input, mapStrategy, builder, arguments); + processCustomMappers(input, mapStrategy, builder); } for (BeanProperty beanProperty : inputIntrospection.getBeanProperties()) { if (!beanProperty.isWriteOnly()) { @@ -302,7 +423,7 @@ private O map(I input, MapStrategy mapStrategy, BeanIntrospection inp return builder.build(); } - private void processCustomMappers(I input, MapStrategy mapStrategy, BeanIntrospection.Builder builder, @NonNull Argument[] arguments) { + private void processCustomMappers(I input, MapStrategy mapStrategy, BeanIntrospection.Builder builder) { Map>> customMappers = mapStrategy.customMappers(); customMappers.forEach((name, func) -> { int i = builder.indexOf(name); @@ -310,6 +431,10 @@ private void processCustomMappers(I input, MapStrategy mapStrategy, BeanI func.accept(input, (BeanIntrospection.Builder) builder); } }); + List>> rootMappers = mapStrategy.rootMappers(); + for (BiConsumer> rootMapper : rootMappers) { + rootMapper.accept(input, (BeanIntrospection.Builder) builder); + } } private O map(Map input, MapStrategy mapStrategy, BeanIntrospection outputIntrospection) { @@ -323,7 +448,7 @@ private void handleMapInput(Map input, MapStrategy mapStrate Mapper.ConflictStrategy conflictStrategy = mapStrategy.conflictStrategy(); boolean isDefault = mapStrategy == MapStrategy.DEFAULT; if (!isDefault) { - processCustomMappers(input, mapStrategy, builder, arguments); + processCustomMappers(input, mapStrategy, builder); } input.forEach((key, value) -> { int i = builder.indexOf(key); @@ -347,20 +472,26 @@ private interface MapInvocation { Object map(MethodInvocationContext invocationContext); } - private record MapStrategy(Mapper.ConflictStrategy conflictStrategy, Map>> customMappers) { - static final MapStrategy DEFAULT = new MapStrategy(Mapper.ConflictStrategy.CONVERT, Collections.emptyMap()); + private record MapStrategy( + Mapper.ConflictStrategy conflictStrategy, + Map>> customMappers, + List>> rootMappers) { + static final MapStrategy DEFAULT = new MapStrategy(Mapper.ConflictStrategy.CONVERT, Collections.emptyMap(), List.of()); private MapStrategy { if (conflictStrategy == null) { conflictStrategy = Mapper.ConflictStrategy.CONVERT; } if (customMappers == null) { - customMappers = new HashMap<>(); + customMappers = new HashMap<>(10); + } + if (rootMappers == null) { + rootMappers = new ArrayList<>(3); } } public MapStrategy(Mapper.ConflictStrategy conflictStrategy) { - this(conflictStrategy, new HashMap<>()); + this(conflictStrategy, new HashMap<>(10), new ArrayList<>(3)); } } } diff --git a/context/src/main/java/io/micronaut/runtime/beans/MapperMethodProcessor.java b/context/src/main/java/io/micronaut/runtime/beans/MapperMethodProcessor.java new file mode 100644 index 00000000000..e88527a0cc3 --- /dev/null +++ b/context/src/main/java/io/micronaut/runtime/beans/MapperMethodProcessor.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.runtime.beans; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Mapper; +import io.micronaut.context.processor.ExecutableMethodProcessor; +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.convert.MutableConversionService; +import io.micronaut.core.util.SupplierUtil; +import io.micronaut.inject.BeanDefinition; +import io.micronaut.inject.ExecutableMethod; +import jakarta.inject.Singleton; + +import java.util.function.Supplier; + +/** + * Triggers registering all bean mappers as type converters as well. + * + * @since 4.2.0 + * @author graemerocher + */ +@Experimental +@Internal +@Singleton +final class MapperMethodProcessor implements ExecutableMethodProcessor { + private final MutableConversionService mutableConversionService; + private final ApplicationContext applicationContext; + + MapperMethodProcessor(MutableConversionService mutableConversionService, ApplicationContext applicationContext) { + this.mutableConversionService = mutableConversionService; + this.applicationContext = applicationContext; + } + + @Override + public void process(BeanDefinition beanDefinition, ExecutableMethod method) { + Class[] argumentTypes = method.getArgumentTypes(); + if (method.hasDeclaredAnnotation(Mapper.class) && argumentTypes.length == 1) { + Class toType = (Class) method.getReturnType().getType(); + Class fromType = (Class) argumentTypes[0]; + ExecutableMethod finalMethod = (ExecutableMethod) method; + Supplier beanSupplier = SupplierUtil.memoized(() -> applicationContext.getBean(beanDefinition)); + mutableConversionService.addConverter( + fromType, + toType, + object -> finalMethod.invoke(beanSupplier.get(), object) + ); + } + } +} diff --git a/context/src/test/groovy/io/micronaut/runtime/beans/MapperAnnotationSpec.groovy b/context/src/test/groovy/io/micronaut/runtime/beans/MapperAnnotationSpec.groovy index 0f393cb2f0e..7b0c778a7e6 100644 --- a/context/src/test/groovy/io/micronaut/runtime/beans/MapperAnnotationSpec.groovy +++ b/context/src/test/groovy/io/micronaut/runtime/beans/MapperAnnotationSpec.groovy @@ -5,6 +5,8 @@ import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Mapper import io.micronaut.core.annotation.AccessorsStyle import io.micronaut.core.annotation.Introspected +import io.micronaut.core.convert.ConversionService +import jakarta.inject.Inject import jakarta.inject.Singleton import spock.lang.AutoCleanup import spock.lang.Shared @@ -13,6 +15,39 @@ import spock.lang.Specification class MapperAnnotationSpec extends Specification { @Shared @AutoCleanup ApplicationContext context = ApplicationContext.run() @Shared Test testBean = context.getBean(Test) + @Shared ConversionService conversionService = context.getBean(ConversionService) + + void "test convert from nested"() { + given: + CreateCommand cmd = new CreateCommand(new CreateRobot("foo", "bar", 10), 123) + + when: + SimpleRobotEntity result = testBean.toEntity(cmd) + + then: + result.id == 'foo' + result.companyId == 'bar' + result.parts == 10 + result.token == "123" + + when:"exercise caching" + result = testBean.toEntity(cmd) + + then: + result.id == 'foo' + result.companyId == 'bar' + result.parts == 10 + result.token == "123" + + when:"conversion" + result = conversionService.convertRequired(cmd, SimpleRobotEntity) + + then: + result.id == 'foo' + result.companyId == 'bar' + result.parts == 10 + result.token == "123" + } void testMapConstructorWithMap() { given: @@ -116,6 +151,9 @@ abstract class Test { @Mapper abstract SimpleRobotEntity toEntity(Map map) + + @Mapper.Mapping(from = "#{cmd.createRobot}") abstract SimpleRobotEntity toEntity(CreateCommand cmd) + @Mapper.Mapping(to = "id", from = "#{map.get('companyId')}") @Mapper abstract SimpleRobotEntity toEntityTransform(Map map) @@ -149,6 +187,17 @@ final class CreateRobot { } } +@Introspected +final class CreateCommand { + final CreateRobot createRobot + final int token + + CreateCommand(CreateRobot createRobot, int token) { + this.createRobot = createRobot + this.token = token + } +} + @Introspected final class CreateRobot2 { final String id diff --git a/inject/src/main/java/io/micronaut/context/annotation/Mapper.java b/inject/src/main/java/io/micronaut/context/annotation/Mapper.java index 0b1cb0fba43..269d8cf6063 100644 --- a/inject/src/main/java/io/micronaut/context/annotation/Mapper.java +++ b/inject/src/main/java/io/micronaut/context/annotation/Mapper.java @@ -34,6 +34,7 @@ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.METHOD, ElementType.TYPE }) @Experimental +@Executable(processOnStartup = true) public @interface Mapper { /** @@ -60,9 +61,11 @@ String MEMBER_DEFAULT_VALUE = "defaultValue"; /** + * The property name to map to. When not specified assume the root bean is being mapped to. + * * @return name of the property to map to. */ - String to(); + String to() default ""; /** * Specifies the name of the property to map from. Can be an expression.