Skip to content

Commit

Permalink
[Feature] Support related node mapping.
Browse files Browse the repository at this point in the history
  • Loading branch information
meistermeier committed Jan 6, 2023
1 parent ed387b0 commit 8f8fd34
Show file tree
Hide file tree
Showing 9 changed files with 120 additions and 49 deletions.
5 changes: 5 additions & 0 deletions docs/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ There is only one mapping phase per instance and this is the instantiation via t
NOTE: If you are working with classes, you need to compile your application with `-parameters` to preserve the constructor's parameter names.
Otherwise, the mapper cannot link the right values.

There is also support to map related nodes.
To have an unambiguous definition, the result has to have the format `RETURN n, [relatedNode] as relatedNodes`
where `relatedNodes` refers to the name of a `List<RelatedNodeType> relatedNodes` defined field.

=== Example

To get started, a node returned from the driver, should get mapped into a record with two fields.
Expand Down Expand Up @@ -164,4 +168,5 @@ the `Renderer` might not support some of them yet.
| OffsetTime | OffsetTime
| ZonedDateTime | ZonedDateTime
| List<> of everything ^^ | [] of everything ^^
| List<RelatedNode> | [] of nodes
|===
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,53 @@ public Converters() {
/**
* Convert given value into target type.
*
* @param mapAccessor input value
* @param type target type
* @param <T> resulting target type
* @return converted value or throws {@link ConversionException} if no suitable converter can be found
* @param <T> resulting target type
* @param mapAccessor input value
* @param type target type
* @param genericTypeParameter generic type of the base type, if needed/provided
* @return converted value or throws {@link ConversionException} if no suitable converter can be found
*/
public <T> T convert(MapAccessor mapAccessor, Class<T> type) {
public <T> T convert(MapAccessor mapAccessor, Class<T> type, Class<?> genericTypeParameter) {
if (mapAccessor == null) {
return null;
}
if (mapAccessor instanceof Value value) {
if (value.isNull()) {
return null;
}

for (ValueConverter valueConverter : internalValueConverters) {
if (valueConverter.canConvert(value, type)) {
return valueConverter.convert(value, type);
if (valueConverter.canConvert(value, type, genericTypeParameter)) {
return valueConverter.convert(value, type, genericTypeParameter);
}
}
}
for (TypeConverter<MapAccessor> typeConverter : internalTypeConverters) {
if (typeConverter.canConvert(mapAccessor, type)) {
return typeConverter.convert(mapAccessor, type);
if (typeConverter.canConvert(mapAccessor, type, genericTypeParameter)) {
return typeConverter.convert(mapAccessor, type, genericTypeParameter);
}
}

throw new ConversionException("Cannot convert %s to %s".formatted(mapAccessor, type));
}

/**
* @param value value to check for. Can be null.
* @param type type to check for.
* @param genericTypeParameter generic type if type is generic
* @return is there at least one converter that supports this type or type / value combination.
*/
public boolean canConvertToJava(Value value, Class<?> type, Class<?> genericTypeParameter) {
for (ValueConverter internalValueConverter : internalValueConverters) {
if (internalValueConverter.canConvert(value, type, genericTypeParameter)) {
return true;
}
}
for (TypeConverter<MapAccessor> internalValueConverter : internalTypeConverters) {
if (internalValueConverter.canConvert(null, type, genericTypeParameter)) {
return true;
}
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,28 +64,28 @@ ZonedDateTime.class, conversion(Value::asZonedDateTime, ZonedDateTime.class)
);

@Override
public boolean canConvert(Value value, Class<?> type) {
public boolean canConvert(Value value, Class<?> type, Class<?> genericTypeParameter) {
return BASIC_CONVERSIONS.containsKey(type)
|| DATE_TIME_CONVERSIONS.containsKey(type)
|| SUPPORTED_SOURCE_VALUES_TYPES.contains(value.type())
|| SUPPORTED_SOURCE_VALUES_TYPES.contains(value.type()) && (genericTypeParameter == null || BASIC_CONVERSIONS.containsKey(genericTypeParameter) || DATE_TIME_CONVERSIONS.containsKey(genericTypeParameter))
|| (SOURCE_VALUE_TARGET_COMBINATION.containsKey(value.type()) && SOURCE_VALUE_TARGET_COMBINATION.containsValue(type));
}

@SuppressWarnings("unchecked")
@Override
public <T> T convert(Value value, Class<T> type) {
public <T> T convert(Value value, Class<T> type, Class<?> genericTypeParameter) {
if (value.isNull()) {
return null;
}
if (typeSystem.MAP().isTypeOf(value)) {
if (type.isAssignableFrom(Map.class)) {
// convert into simple map
return (T) value.asMap(mapValue -> convert(mapValue, String.class));
return (T) value.asMap(mapValue -> convert(mapValue, String.class, genericTypeParameter));
}
}

if (!type.isArray() && typeSystem.LIST().isTypeOf(value)) {
return (T) value.asList(nestedValue -> convert(nestedValue, type));
return (T) value.asList(nestedValue -> convert(nestedValue, genericTypeParameter, null));
}
for (DriverTypeConversion conversion : BASIC_CONVERSIONS.values()) {
if (conversion.predicate.test(type)) {
Expand All @@ -98,7 +98,7 @@ public <T> T convert(Value value, Class<T> type) {
}
}
if (type.isArray() && typeSystem.LIST().isTypeOf(value)) {
return (T) value.asList(nestedValue -> convert(nestedValue, type.getComponentType()));
return (T) value.asList(nestedValue -> convert(nestedValue, type.getComponentType(), genericTypeParameter));
}
throw new ConversionException("Cannot convert %s to %s".formatted(value, type));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
import org.neo4j.driver.types.MapAccessor;
import org.neo4j.driver.types.TypeSystem;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
* Entity converter that delegates to the {@link ObjectInstantiator}
Expand All @@ -39,23 +43,46 @@ public EntityConverter(Converters converters) {
}

@Override
public boolean canConvert(MapAccessor mapAccessor, Class<?> type) {
public boolean canConvert(MapAccessor mapAccessor, Class<?> type, Class<?> genericTypeParameter) {
if (mapAccessor instanceof Value value) {
return typeSystem.NODE().isTypeOf(value) || typeSystem.MAP().isTypeOf(value);
return typeSystem.NODE().isTypeOf(value) || typeSystem.MAP().isTypeOf(value) || typeSystem.LIST().isTypeOf(value);
}
return mapAccessor instanceof Record;
}

@Override
public <T> T convert(MapAccessor record, Class<T> entityClass) {
Map<String, Object> recordMap = record.asMap();
if (recordMap.size() == 1) {
Value value = record.values().iterator().next();
if (value.type().equals(typeSystem.NODE())) {
return objectInstantiator.createInstance(entityClass, converters).apply(value);
public <T> T convert(MapAccessor mapAccessor, Class<T> entityClass, Class<?> genericTypeParameter) {
if (mapAccessor instanceof Value value && typeSystem.LIST().isTypeOf(value)) {
List<T> collectionEntities = new ArrayList<>();
for (Value ding : value.asList(Function.identity())) {
HeadAndTail headAndTail = HeadAndTail.from(ding, typeSystem);
T entity = (T) objectInstantiator.createInstance(genericTypeParameter, headAndTail.head(), headAndTail.tail(), converters);
collectionEntities.add(entity);
}
// yes, I know that List<T> is not <T> but ¯\_(ツ)_/¯
return (T) collectionEntities;
}
HeadAndTail headAndTail = HeadAndTail.from(mapAccessor, typeSystem);
return objectInstantiator.createInstance(entityClass, headAndTail.head(), headAndTail.tail(), converters);
}

private record HeadAndTail(MapAccessor head, Map<String, MapAccessor> tail) {
static HeadAndTail from(MapAccessor mapAccessor, TypeSystem typeSystem) {
if (mapAccessor instanceof Value value && typeSystem.NODE().isTypeOf(value)) {
return new HeadAndTail(mapAccessor, Map.of());
}
for (Value value : mapAccessor.values()) {
if (typeSystem.NODE().isTypeOf(value)) {
// maybe there is a "faster" way of getting this values converted into MapAccessor
Map<String, Value> ding = mapAccessor.asMap(Function.identity());
var tailValue = ding.entrySet().stream()
.filter(entryToCheck -> entryToCheck.getValue() != value)
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (MapAccessor) entry.getValue()));

return new HeadAndTail(value, tailValue);
}
}
return new HeadAndTail(mapAccessor, Map.of());
}
// plain property based result
return objectInstantiator.createInstance(entityClass, converters).apply(record);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.meistermeier.neo4j.toolbelt.conversion;

import org.neo4j.driver.Value;
import org.neo4j.driver.Values;
import org.neo4j.driver.types.MapAccessor;

import java.lang.reflect.Constructor;
Expand All @@ -28,7 +29,6 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

/**
* Instantiates objects from class or records and populates their fields,
Expand All @@ -47,8 +47,8 @@ class ObjectInstantiator {
* @param <T> Type to process and return.
* @return New populated instance of the defined type.
*/
<T> Function<MapAccessor, T> createInstance(Class<T> entityClass, Converters converters) {
return record -> {
<T> T createInstance(Class<T> entityClass, MapAccessor record, Map<String, MapAccessor> tail, Converters converters) {

Constructor<T> instantiatingConstructor = determineConstructor(entityClass, record.asMap());

Parameter[] parameters = instantiatingConstructor.getParameters();
Expand All @@ -57,21 +57,26 @@ <T> Function<MapAccessor, T> createInstance(Class<T> entityClass, Converters con
Parameter parameter = parameters[i];
String parameterName = parameter.getName();
Value value = record.get(parameterName);
values[i] = value;
Class<?> parameterType = parameter.getType();
if (converters.canConvertToJava(value, parameterType, getType(parameters[i]))) {
values[i] = value;
} else if (parameterType.isAssignableFrom(List.class)) {
// look into the tail
values[i] = (Value) tail.getOrDefault(parameterName, Values.NULL);
}
}

try {
Object[] rawValues = new Object[values.length];
for (int i = 0; i < values.length; i++) {
Value value = values[i];
rawValues[i] = converters.convert(value, getType(parameters[i]));
rawValues[i] = converters.convert(value, parameters[i].getType(), getType(parameters[i]));
}
return instantiatingConstructor.newInstance(rawValues);
} catch (ClassNotFoundException | InstantiationException | IllegalAccessException |
} catch (InstantiationException | IllegalAccessException |
InvocationTargetException e) {
throw new RuntimeException(e);
}
};
}

/**
Expand Down Expand Up @@ -114,7 +119,7 @@ private int calculateIntersectionAmount(Collection<String> constructorParameterN

}

private static Class<?> getType(Parameter parameter) throws ClassNotFoundException {
private static Class<?> getType(Parameter parameter) {
if (parameter.getType().isAssignableFrom(Map.class)) {
return Map.class;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,21 @@ public interface TypeConverter<T> {
/**
* Reports if this converter can convert the given type.
*
* @param value the value to convert
* @param type the field type the converter should convert the value to
* @param value the value to convert
* @param type the field type the converter should convert the value to
* @param genericTypeParameter generic type of the type, if needed/provided
* @return true, if the converter takes responsibility for this type, otherwise false
*/
boolean canConvert(T value, Class<?> type);
boolean canConvert(T value, Class<?> type, Class<?> genericTypeParameter);

/**
* Converts the given driver value into the requested type.
*
* @param value the value to convert
* @param type the field type the converter should convert the value to
* @param <X> Expected type of the returned object
* @param <X> Expected type of the returned object
* @param value the value to convert
* @param type the field type the converter should convert the value to
* @param genericTypeParameter generic type of the type, if needed/provided
* @return the converted value object
*/
<X> X convert(T value, Class<X> type);
<X> X convert(T value, Class<X> type, Class<?> genericTypeParameter);
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public <T> Function<Record, Iterable<T>> createCollectionMapperFor(Class<T> type
}

private <T> T mapOne(MapAccessor mapAccessor, Class<T> type) {
return converters.convert(mapAccessor, type);
return converters.convert(mapAccessor, type, null);
}

private <T> Iterable<T> mapAll(Record record, Class<T> type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
*/
package com.meistermeier.neo4j.toolbelt.conversion;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
Expand Down Expand Up @@ -74,7 +72,7 @@ private static Stream<Arguments> convertSimpleTypes() {
@ParameterizedTest
@MethodSource
void convertSimpleTypes(Value sourceValue, Object expected) {
Object result = driverValueConverter.convert(sourceValue, expected.getClass());
Object result = driverValueConverter.convert(sourceValue, expected.getClass(), null);
assertThat(result).isEqualTo(expected);
}

Expand Down Expand Up @@ -116,7 +114,7 @@ private static Stream<Arguments> convertListTypes() {
@ParameterizedTest
@MethodSource
void convertListTypes(Value sourceValue, Object expected, Class<?> expectedClass) {
Object result = driverValueConverter.convert(sourceValue, expectedClass);
Object result = driverValueConverter.convert(sourceValue, expectedClass, null);
assertThat(result).isEqualTo(expected);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,14 @@ public class ConstructorBasedReadingIT {
@BeforeAll
static void setupDriverAndData() {
driver = GraphDatabase.driver(container.getBoltUrl());
driver.session().run("CREATE (:Node{a: 'a1', b: 'b1', c: ['a1', 'b1', 'c1']})").consume();
driver.session().run("CREATE (:Node{a: 'a1', b: 'b1', c: ['a1', 'b1', 'c1']})-[:REL]->(:Dings{a:'relatedRecord'})").consume();
driver.session().run("CREATE (:Node{a: 'a2', b: 'b2', c: ['a2', 'b2', 'c2']})").consume();
}

@Test
void mapRecordFromNode() {
try (var session = driver.session()) {
List<ConversionTargetRecord> result = session.run("MATCH (n:Node{a:'a1'}) return n")
List<ConversionTargetRecord> result = session.run("MATCH (n:Node{a:'a1'})-->(relatedRecord) return n, [relatedRecord] as relatedRecord")
.list(mapper.createMapperFor(ConversionTargetRecord.class));

assertThat(result).hasSize(1);
Expand All @@ -63,6 +63,11 @@ void mapRecordFromNode() {
assertThat(converted.c())
.hasSize(3)
.containsExactly("a1", "b1", "c1");
assertThat(converted.relatedRecord())
.isNotNull()
.isNotEmpty()
.extracting("a")
.containsExactly("relatedRecord");
}
}

Expand Down Expand Up @@ -118,8 +123,9 @@ void mapListOfClassFromNode() {
}
}

public record ConversionTargetRecord(String a, String b, List<String> c) {
}
public record ConversionTargetRecord(String a, String b, List<String> c, List<RelatedRecord> relatedRecord) { }

public record RelatedRecord(String a) {}

public static class ConversionTargetClass {
public final String a;
Expand Down

0 comments on commit 8f8fd34

Please sign in to comment.