
io.yupiik.batch.runtime.component.Mapper Maven / Gradle / Ivy
/*
* Copyright (c) 2021 - Yupiik SAS - https://www.yupiik.com
* 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
*
* http://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.yupiik.batch.runtime.component;
import io.yupiik.batch.runtime.documentation.Component;
import io.yupiik.batch.runtime.component.mapping.Mapping;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Stream;
import static java.util.Comparator.comparing;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toMap;
// todo: nested objects support if needed (mapper of mapper in terms of impl, nothing more crazy)
@Component("""
This mapping component enables to convert an input to an output instance by providing an specification instance.
It is a class decorated with `@Mapping`:
[source,java]
----
@Mapping(
from = IncomingModel.class,
to = OutputModel.class,
documentation = "Converts an input to an output.",
properties = {
@Property(type = CONSTANT, to = "outputFieldUrl", value = "https://foo.bar/"),
@Property(type = TABLE_MAPPING, from = "inputKeyField", to = "mappedOutput", value = "myLookupTable", onMissedTableLookup = FORWARD)
},
tables = {
@Mapping.MappingTable(
name = "myLookupTable",
entries = {
@Entry(input = "A", output = "1"),
@Entry(input = "C", output = "3")
}
)
})
public class MyMapperSpec {
@Mapping.Custom(description = "This will map X to Y.")
String outputField(final IncomingModel in[, @Table("myLookupTable") final Map myLookupTable) {
return ...;
}
}
----
To get a mapper, you simply call `Mapper.mapper(MyMapperSpec.class)` and then can insert this mapper in any `BatchChain`.
The specification API enables static mapping (`properties`) or custom mapping - `@Mapping.Custom` - for more advanced logic.
The companion class `io.yupiik.batch.runtime.documentation.MapperDocGenerator` enables to generate an asciidoctor documentation for a mapper class.""")
public class Mapper implements Function {
private final Function delegate;
private final C delegateInstance;
public Mapper(final Class spec) {
if (!spec.isInterface() && !Modifier.isAbstract(spec.getModifiers())) {
C instance = null;
try {
instance = spec.getConstructor().newInstance();
} catch (final NoSuchMethodException nsme) {
// no-op
} catch (final InstantiationException | IllegalAccessException e) {
throw new IllegalStateException(e);
} catch (final InvocationTargetException e) {
throw new IllegalStateException(e.getTargetException());
}
delegateInstance = instance;
} else {
delegateInstance = null;
}
this.delegate = createMapper(spec);
}
public C getDelegateInstance() {
return delegateInstance;
}
@Override
public B apply(final A a) {
return delegate.apply(a);
}
private Function createMapper(final Class> spec) {
final var conf = spec.getAnnotation(Mapping.class);
if (conf == null) {
throw new IllegalArgumentException("No @Mapping on " + spec);
}
final var from = conf.from();
final var to = conf.to();
final var tableMappings = collectMappingTables(conf);
final var toConstructor = Stream.of(to.getDeclaredConstructors())
.max(comparing(Constructor::getParameterCount)) // a bit random but likely works for "pure" records
.map(c -> {
c.trySetAccessible();
return c;
})
.orElseThrow(() -> new IllegalArgumentException("No constructor for " + to));
final var toOrderedProperties = Stream.of(toConstructor.getParameters()).collect(toList());
final var mappers = collectMappers(spec, conf, from, tableMappings, toOrderedProperties);
// prepare mapper in order + add missing one (default value)
final var orderedMappers = toOrderedProperties.stream()
.map(it -> ofNullable(mappers.get(it.getName()))
.orElseGet(() -> {
final var type = it.getType();
if (type.isPrimitive()) {
return toPrimitiveDefaultMapper(type);
}
return i -> null;
}))
.collect(toList());
return input -> {
if (!from.isInstance(input)) {
throw new IllegalArgumentException("Unsupported input: " + input + ", expected: " + from);
}
try {
final var params = orderedMappers.stream()
.map(it -> it.apply(input))
.toArray(Object[]::new);
final var newInstance = toConstructor.newInstance(params);
return (B) newInstance;
} catch (final InstantiationException | IllegalAccessException e) {
throw new IllegalStateException(e);
} catch (final InvocationTargetException e) {
throw new IllegalStateException(e.getTargetException());
}
};
}
private Map> collectMappers(final Class> spec, final Mapping conf,
final Class> from,
final Map> tableMappings,
final List toOrderedProperties) {
return Stream.of(
Stream.of(conf.properties())
.peek(c -> c.type().validate(c))
.collect(toMap(Mapping.Property::to, c -> toPropertyMapper(c, toOrderedProperties, tableMappings))),
findMethods(spec)
.collect(toMap(m -> {
final var target = m.getAnnotation(Mapping.Custom.class).to();
return target.isEmpty() ? m.getName() : target;
}, i -> toCustomPropertyMapper(from, i, tableMappings))))
.flatMap(m -> m.entrySet().stream())
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
}
private Map> collectMappingTables(final Mapping conf) {
return Stream.of(conf.tables())
.collect(toMap(Mapping.MappingTable::name, mt -> Stream.of(mt.entries())
.collect(toMap(Mapping.Entry::input, Mapping.Entry::output))));
}
private Stream findMethods(final Class> spec) {
if (spec == null || spec == Object.class) {
return Stream.empty();
}
return Stream.concat(
Stream.of(spec.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(Mapping.Custom.class)),
findMethods(spec.getSuperclass()));
}
private Function
© 2015 - 2025 Weber Informatics LLC | Privacy Policy