Skip to content

Commit

Permalink
Improve usability of @Mapper annotation (#9980)
Browse files Browse the repository at this point in the history
This PR adds a couple of small improvements to the bean mapping API. 

* Register type converters for each mapper method
* Allow omitting `to` argument which assumes you are binding to the root entity
  • Loading branch information
graemerocher authored Oct 17, 2023
1 parent 5ef2de2 commit 11bd400
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 14 deletions.
157 changes: 144 additions & 13 deletions context/src/main/java/io/micronaut/runtime/beans/MapperIntroduction.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -97,11 +101,18 @@ public Object intercept(MethodInvocationContext<Object, Object> context) {
isMap
);

List<Function<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>>> 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<String, Object>) callContext.getParameterValues()[0],
mapStrategy,
Expand All @@ -110,7 +121,7 @@ public Object intercept(MethodInvocationContext<Object, Object> context) {
};
} else {
invocation = callContext -> {
MapStrategy mapStrategy = buildMapStrategy(conflictStrategy, customMappers, callContext);
MapStrategy mapStrategy = buildMapStrategy(conflictStrategy, customMappers, rootMappers, callContext);
return map(
callContext.getParameterValues()[0],
mapStrategy,
Expand All @@ -137,14 +148,123 @@ public Object intercept(MethodInvocationContext<Object, Object> context) {
}
}

private static MapStrategy buildMapStrategy(Mapper.ConflictStrategy conflictStrategy, Map<String, Function<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>>> customMappers, MethodInvocationContext<Object, Object> callContext) {
private @Nullable List<Function<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>>> buildRootMappers(
BeanIntrospection<Object> fromIntrospection,
Mapper.ConflictStrategy conflictStrategy,
List<AnnotationValue<Mapper.Mapping>> annotations,
boolean isMap) {
List<Function<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>>> rootMappers = new ArrayList<>(5);
for (AnnotationValue<Mapper.Mapping> 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<CharSequence, Object> 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<Object, Object> 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<String, Object>) 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<Object> builder, Object object) {
BeanIntrospection<Object> nestedFrom;
try {
//noinspection unchecked
nestedFrom = (BeanIntrospection<Object>) 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<BeanProperty<Object, Object>> propertyNames = nestedFrom.getBeanProperties();
for (BeanProperty<Object, Object> property : propertyNames) {
if (property.isWriteOnly()) {
continue;
}
int i = builder.indexOf(property.getName());
if (i > -1) {
@SuppressWarnings("unchecked")
Argument<Object> argument = (Argument<Object>) 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<String, Function<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>>> customMappers,
@Nullable List<Function<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>>> rootMappers,
MethodInvocationContext<Object, Object> 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<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>> 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<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>> mapSupplier : rootMappers) {
mapStrategy.rootMappers.add(mapSupplier.apply(null));
}
}
}

return mapStrategy;
Expand All @@ -157,18 +277,19 @@ private Map<String, Function<Object, BiConsumer<Object, BeanIntrospection.Builde
List<AnnotationValue<Mapper.Mapping>> annotations,
boolean isMap) {
Map<String, Function<Object, BiConsumer<Object, BeanIntrospection.Builder<Object>>>> customMappers = new HashMap<>();
BeanIntrospection.Builder<Object> builderMeta = toIntrospection.builder();
@NonNull Argument<?>[] builderArguments = builderMeta.getBuilderArguments();
for (AnnotationValue<Mapper.Mapping> mapping : annotations) {
String to = mapping.stringValue(Mapper.Mapping.MEMBER_TO).orElse(null);
String format = mapping.stringValue(Mapper.Mapping.MEMBER_FORMAT).orElse(null);
BeanIntrospection.Builder<Object> builderMeta = toIntrospection.builder();


if (StringUtils.isNotEmpty(to)) {
int i = builderMeta.indexOf(to);
if (i == -1) {
continue;
}
@SuppressWarnings("unchecked") Argument<Object> argument = (Argument<Object>) builderMeta.getBuilderArguments()[i];
@SuppressWarnings("unchecked") Argument<Object> argument = (Argument<Object>) builderArguments[i];
ArgumentConversionContext<?> conversionContext = null;
if (format != null) {
conversionContext = ConversionContext.of(argument);
Expand Down Expand Up @@ -276,7 +397,7 @@ private <I, O> O map(I input, MapStrategy mapStrategy, BeanIntrospection<I> inp
@SuppressWarnings("unchecked") @NonNull Argument<Object>[] arguments = (Argument<Object>[]) builder.getBuilderArguments();

if (!isDefault) {
processCustomMappers(input, mapStrategy, builder, arguments);
processCustomMappers(input, mapStrategy, builder);
}
for (BeanProperty<I, Object> beanProperty : inputIntrospection.getBeanProperties()) {
if (!beanProperty.isWriteOnly()) {
Expand All @@ -302,14 +423,18 @@ private <I, O> O map(I input, MapStrategy mapStrategy, BeanIntrospection<I> inp
return builder.build();
}

private <I, O> void processCustomMappers(I input, MapStrategy mapStrategy, BeanIntrospection.Builder<O> builder, @NonNull Argument<Object>[] arguments) {
private <I, O> void processCustomMappers(I input, MapStrategy mapStrategy, BeanIntrospection.Builder<O> builder) {
Map<String, BiConsumer<Object, BeanIntrospection.Builder<Object>>> customMappers = mapStrategy.customMappers();
customMappers.forEach((name, func) -> {
int i = builder.indexOf(name);
if (i > -1) {
func.accept(input, (BeanIntrospection.Builder<Object>) builder);
}
});
List<BiConsumer<Object, BeanIntrospection.Builder<Object>>> rootMappers = mapStrategy.rootMappers();
for (BiConsumer<Object, BeanIntrospection.Builder<Object>> rootMapper : rootMappers) {
rootMapper.accept(input, (BeanIntrospection.Builder<Object>) builder);
}
}

private <O> O map(Map<String, Object> input, MapStrategy mapStrategy, BeanIntrospection<O> outputIntrospection) {
Expand All @@ -323,7 +448,7 @@ private <O> void handleMapInput(Map<String, Object> 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);
Expand All @@ -347,20 +472,26 @@ private interface MapInvocation {
Object map(MethodInvocationContext<Object, Object> invocationContext);
}

private record MapStrategy(Mapper.ConflictStrategy conflictStrategy, Map<String, BiConsumer<Object, BeanIntrospection.Builder<Object>>> customMappers) {
static final MapStrategy DEFAULT = new MapStrategy(Mapper.ConflictStrategy.CONVERT, Collections.emptyMap());
private record MapStrategy(
Mapper.ConflictStrategy conflictStrategy,
Map<String, BiConsumer<Object, BeanIntrospection.Builder<Object>>> customMappers,
List<BiConsumer<Object, BeanIntrospection.Builder<Object>>> 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));
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Mapper> {
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<Object> toType = (Class<Object>) method.getReturnType().getType();
Class<Object> fromType = (Class<Object>) argumentTypes[0];
ExecutableMethod<Object, Object> finalMethod = (ExecutableMethod<Object, Object>) method;
Supplier<?> beanSupplier = SupplierUtil.memoized(() -> applicationContext.getBean(beanDefinition));
mutableConversionService.addConverter(
fromType,
toType,
object -> finalMethod.invoke(beanSupplier.get(), object)
);
}
}
}
Loading

0 comments on commit 11bd400

Please sign in to comment.