diff --git a/etc/jqassistant/structure.adoc b/etc/jqassistant/structure.adoc index 0e98a1079..871b61d85 100644 --- a/etc/jqassistant/structure.adoc +++ b/etc/jqassistant/structure.adoc @@ -5,12 +5,13 @@ Most of the time, the package structure under `org.neo4j.springframework.data` s [[structure:mapping]] [source,cypher,role=constraint,requiresConcepts="dependency:Package"] -.The mapping package must not depend on any other SDN/RX packages than `schema` +.The mapping package must not depend on any other SDN/RX packages than `schema` and `convert` ---- MATCH (a:Main:Artifact) -MATCH (a) -[:CONTAINS]-> (s:Package) WHERE s.fqn = 'org.neo4j.springframework.data.core.schema' +OPTIONAL MATCH (a) -[:CONTAINS]-> (s:Package) WHERE s.fqn in ['org.neo4j.springframework.data.core.schema', 'org.neo4j.springframework.data.core.convert'] +WITH collect(s) as allowed, a MATCH (a) -[:CONTAINS]-> (p1:Package) -[:DEPENDS_ON]-> (p2:Package) <-[:CONTAINS]- (a) WHERE p1.fqn = 'org.neo4j.springframework.data.core.mapping' - AND NOT (p2 = s OR (p1) -[:CONTAINS]-> (p2)) + AND NOT (p2 in allowed OR (p1) -[:CONTAINS]-> (p2)) return p1,p2 ---- diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractNeo4jConfig.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractNeo4jConfig.java index 535474bac..33e5135c1 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractNeo4jConfig.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractNeo4jConfig.java @@ -21,9 +21,11 @@ package org.neo4j.springframework.data.config; import org.apiguardian.api.API; import org.neo4j.driver.Driver; import org.neo4j.springframework.data.core.Neo4jClient; +import org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager; import org.neo4j.springframework.data.repository.config.Neo4jRepositoryConfigurationExtension; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; /** * Base class for imperative SDN-RX configuration using JavaConfig. @@ -37,6 +39,13 @@ import org.springframework.context.annotation.Configuration; @API(status = API.Status.STABLE, since = "1.0") public abstract class AbstractNeo4jConfig extends Neo4jConfigurationSupport { + /** + * The driver to be used for interacting with Neo4j. + * + * @return the Neo4j Java driver instance to work with. + */ + public abstract Driver driver(); + /** * The driver used here should be the driver resulting from {@link #driver()}, which is the default. * @@ -47,4 +56,16 @@ public abstract class AbstractNeo4jConfig extends Neo4jConfigurationSupport { public Neo4jClient neo4jClient(Driver driver) { return Neo4jClient.create(driver); } + + /** + * Provides a {@link PlatformTransactionManager} for Neo4j based on the driver resulting from {@link #driver()}. + * + * @param driver The driver to synchronize against + * @return A platform transaction manager + */ + @Bean(Neo4jRepositoryConfigurationExtension.DEFAULT_TRANSACTION_MANAGER_BEAN_NAME) + public PlatformTransactionManager transactionManager(Driver driver) { + + return new Neo4jTransactionManager(driver); + } } diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractReactiveNeo4jConfig.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractReactiveNeo4jConfig.java index f08d6763e..231fde66e 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractReactiveNeo4jConfig.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/AbstractReactiveNeo4jConfig.java @@ -20,21 +20,32 @@ package org.neo4j.springframework.data.config; import org.apiguardian.api.API; import org.neo4j.driver.Driver; +import org.neo4j.springframework.data.core.transaction.ReactiveNeo4jTransactionManager; import org.neo4j.springframework.data.repository.config.ReactiveNeo4jRepositoryConfigurationExtension; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.neo4j.springframework.data.core.ReactiveNeo4jClient; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.ReactiveTransactionManager; /** * Base class for reactive SDN-RX configuration using JavaConfig. * This can be included in all scenarios in which Spring Boot is not an option. * * @author Gerrit Meier + * @author Michael J. Simons * @since 1.0 */ @Configuration @API(status = API.Status.STABLE, since = "1.0") -public abstract class AbstractReactiveNeo4jConfig extends ReactiveNeo4jConfigurationSupport { +public abstract class AbstractReactiveNeo4jConfig extends Neo4jConfigurationSupport { + + /** + * The driver to be used for interacting with Neo4j. + * + * @return the Neo4j Java driver instance to work with. + */ + public abstract Driver driver(); /** * The driver used here should be the driver resulting from {@link #driver()}, which is the default. @@ -46,4 +57,16 @@ public abstract class AbstractReactiveNeo4jConfig extends ReactiveNeo4jConfigura public ReactiveNeo4jClient neo4jClient(Driver driver) { return ReactiveNeo4jClient.create(driver); } + + /** + * Provides a {@link PlatformTransactionManager} for Neo4j based on the driver resulting from {@link #driver()}. + * + * @param driver The driver to synchronize against + * @return A platform transaction manager + */ + @Bean(ReactiveNeo4jRepositoryConfigurationExtension.DEFAULT_TRANSACTION_MANAGER_BEAN_NAME) + public ReactiveTransactionManager reactiveTransactionManager(Driver driver) { + + return new ReactiveNeo4jTransactionManager(driver); + } } diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jConfigurationSupport.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jConfigurationSupport.java index 388307328..74726ff37 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jConfigurationSupport.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/Neo4jConfigurationSupport.java @@ -24,37 +24,32 @@ import java.util.HashSet; import java.util.Set; import org.apiguardian.api.API; -import org.neo4j.driver.Driver; -import org.neo4j.springframework.data.repository.config.Neo4jRepositoryConfigurationExtension; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; +import org.neo4j.springframework.data.core.schema.Node; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.core.type.filter.AnnotationTypeFilter; -import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; -import org.neo4j.springframework.data.core.schema.Node; -import org.neo4j.springframework.data.core.transaction.Neo4jTransactionManager; -import org.springframework.transaction.PlatformTransactionManager; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; /** * Internal support class for basic configuration. The support infrastructure here is basically all around finding out about - * which classes are to be mapped and which not. The driver is part of the configuration support, as Neo4js Java driver - * contains both imperative and reactive components. + * which classes are to be mapped and which not. The driver needs to be configured from a class either extending + * {@link AbstractNeo4jConfig} for imperative or {@link AbstractReactiveNeo4jConfig} for reactive programming model. * * @author Michael J. Simons * @author Gerrit Meier * @since 1.0 */ @API(status = API.Status.STABLE, since = "1.0") -public abstract class Neo4jConfigurationSupport { +abstract class Neo4jConfigurationSupport { - /** - * The driver to be used for interacting with Neo4j. - * - * @return the Neo4j Java driver instance to work with. - */ - public abstract Driver driver(); + @Bean + public Neo4jConversions neo4jConversions() { + return new Neo4jConversions(); + } /** * Creates a {@link org.neo4j.springframework.data.core.mapping.Neo4jMappingContext} equipped with entity classes @@ -64,26 +59,14 @@ public abstract class Neo4jConfigurationSupport { * @see #getMappingBasePackages() */ @Bean - public Neo4jMappingContext neo4jMappingContext() throws ClassNotFoundException { + public Neo4jMappingContext neo4jMappingContext(Neo4jConversions neo4JConversions) throws ClassNotFoundException { - Neo4jMappingContext mappingContext = new Neo4jMappingContext(); + Neo4jMappingContext mappingContext = new Neo4jMappingContext(neo4JConversions); mappingContext.setInitialEntitySet(getInitialEntitySet()); return mappingContext; } - /** - * Provides a {@link PlatformTransactionManager} for Neo4j based on the driver resulting from {@link #driver()}. - * - * @param driver The driver to synchronize against - * @return A platform transaction manager - */ - @Bean(Neo4jRepositoryConfigurationExtension.DEFAULT_TRANSACTION_MANAGER_BEAN_NAME) - public PlatformTransactionManager transactionManager(Driver driver) { - - return new Neo4jTransactionManager(driver); - } - /** * Returns the base packages to scan for Neo4j mapped entities at startup. Will return the package name of the * configuration class' (the concrete class, not this one here) by default. So if you have a diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/ReactiveNeo4jConfigurationSupport.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/ReactiveNeo4jConfigurationSupport.java deleted file mode 100644 index f7579ba44..000000000 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/ReactiveNeo4jConfigurationSupport.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2019 "Neo4j," - * Neo4j Sweden AB [https://neo4j.com] - * - * This file is part of Neo4j. - * - * 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 org.neo4j.springframework.data.config; - -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -import org.apiguardian.api.API; -import org.neo4j.driver.Driver; -import org.neo4j.springframework.data.repository.config.ReactiveNeo4jRepositoryConfigurationExtension; -import org.springframework.beans.factory.config.BeanDefinition; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; -import org.springframework.core.type.filter.AnnotationTypeFilter; -import org.neo4j.springframework.data.core.mapping.Neo4jMappingContext; -import org.neo4j.springframework.data.core.schema.Node; -import org.neo4j.springframework.data.core.transaction.ReactiveNeo4jTransactionManager; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.ReactiveTransactionManager; -import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; - -/** - * Internal support class for basic configuration. The support infrastructure here is basically all around finding out about - * which classes are to be mapped and which not. The driver is part of the configuration support, as Neo4js Java driver - * contains both imperative and reactive components. - * - * @author Michael J. Simons - * @author Gerrit Meier - * @since 1.0 - */ -@API(status = API.Status.STABLE, since = "1.0") -public abstract class ReactiveNeo4jConfigurationSupport { - - /** - * The driver to be used for interacting with Neo4j. - * - * @return the Neo4j Java driver instance to work with. - */ - public abstract Driver driver(); - - /** - * Creates a {@link Neo4jMappingContext} equipped with entity classes - * scanned from the mapping base package. - * - * @return A new {@link Neo4jMappingContext} with initial classes to scan for entities set. - * @see #getMappingBasePackages() - */ - @Bean - public Neo4jMappingContext neo4jMappingContext() throws ClassNotFoundException { - - Neo4jMappingContext mappingContext = new Neo4jMappingContext(); - mappingContext.setInitialEntitySet(getInitialEntitySet()); - - return mappingContext; - } - - /** - * Provides a {@link PlatformTransactionManager} for Neo4j based on the driver resulting from {@link #driver()}. - * - * @param driver The driver to synchronize against - * @return A platform transaction manager - */ - @Bean(ReactiveNeo4jRepositoryConfigurationExtension.DEFAULT_TRANSACTION_MANAGER_BEAN_NAME) - public ReactiveTransactionManager reactiveTransactionManager(Driver driver) { - - return new ReactiveNeo4jTransactionManager(driver); - } - - /** - * Returns the base packages to scan for Neo4j mapped entities at startup. Will return the package name of the - * configuration class' (the concrete class, not this one here) by default. So if you have a - * {@code com.acme.AppConfig} extending {@link ReactiveNeo4jConfigurationSupport} the base package will be considered - * {@code com.acme} unless the method is overridden to implement alternate behavior. - * - * @return the base packages to scan for mapped {@link Node} classes - * or an empty collection to not enable scanning for entities. - */ - protected Collection getMappingBasePackages() { - - Package mappingBasePackage = getClass().getPackage(); - return Collections.singleton(mappingBasePackage == null ? null : mappingBasePackage.getName()); - } - - private Set> getInitialEntitySet() throws ClassNotFoundException { - - Set> initialEntitySet = new HashSet>(); - - for (String basePackage : getMappingBasePackages()) { - initialEntitySet.addAll(scanForEntities(basePackage)); - } - - return initialEntitySet; - } - - private Set> scanForEntities(String basePackage) throws ClassNotFoundException { - - if (!StringUtils.hasText(basePackage)) { - return Collections.emptySet(); - } - - Set> initialEntitySet = new HashSet>(); - - ClassPathScanningCandidateComponentProvider componentProvider = - new ClassPathScanningCandidateComponentProvider(false); - componentProvider.addIncludeFilter(new AnnotationTypeFilter(Node.class)); - - ClassLoader classLoader = ReactiveNeo4jConfigurationSupport.class.getClassLoader(); - for (BeanDefinition candidate : componentProvider.findCandidateComponents(basePackage)) { - initialEntitySet.add(ClassUtils.forName(candidate.getBeanClassName(), classLoader)); - } - - return initialEntitySet; - } -} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultNeo4jClient.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultNeo4jClient.java index c3d2333cf..a1a81dfce 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultNeo4jClient.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultNeo4jClient.java @@ -45,7 +45,11 @@ import org.neo4j.driver.StatementRunner; import org.neo4j.driver.exceptions.NoSuchRecordException; import org.neo4j.driver.summary.ResultSummary; import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; import org.neo4j.springframework.data.repository.NoResultException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -62,11 +66,15 @@ class DefaultNeo4jClient implements Neo4jClient { private final Driver driver; private final TypeSystem typeSystem; + private final ConversionService conversionService; DefaultNeo4jClient(Driver driver) { this.driver = driver; this.typeSystem = driver.defaultTypeSystem(); + + this.conversionService = new DefaultConversionService(); + new Neo4jConversions().registerConvertersIn((ConverterRegistry) conversionService); } AutoCloseableStatementRunner getStatementRunner(@Nullable final String targetDatabase) { @@ -244,7 +252,7 @@ class DefaultNeo4jClient implements Neo4jClient { public MappingSpec, Collection, T> fetchAs(Class targetClass) { return new DefaultRecordFetchSpec(this.targetDatabase, this.runnableStatement, - new SingleValueMappingFunction(targetClass)); + new SingleValueMappingFunction(conversionService, targetClass)); } @Override diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultReactiveNeo4jClient.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultReactiveNeo4jClient.java index 76bc11ca8..fc281c1d5 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultReactiveNeo4jClient.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/DefaultReactiveNeo4jClient.java @@ -41,7 +41,11 @@ import org.neo4j.driver.types.TypeSystem; import org.neo4j.springframework.data.core.Neo4jClient.MappingSpec; import org.neo4j.springframework.data.core.Neo4jClient.OngoingBindSpec; import org.neo4j.springframework.data.core.Neo4jClient.RecordFetchSpec; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; import org.reactivestreams.Publisher; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -58,11 +62,15 @@ class DefaultReactiveNeo4jClient implements ReactiveNeo4jClient { private final Driver driver; private final TypeSystem typeSystem; + private final ConversionService conversionService; DefaultReactiveNeo4jClient(Driver driver) { this.driver = driver; this.typeSystem = driver.defaultTypeSystem(); + + this.conversionService = new DefaultConversionService(); + new Neo4jConversions().registerConvertersIn((ConverterRegistry) conversionService); } Mono retrieveRxStatementRunnerHolder(String targetDatabase) { @@ -184,7 +192,7 @@ class DefaultReactiveNeo4jClient implements ReactiveNeo4jClient { public MappingSpec, Flux, R> fetchAs(Class targetClass) { return new DefaultReactiveRecordFetchSpec<>(this.targetDatabase, this.cypherSupplier, this.parameters, - new SingleValueMappingFunction(targetClass)); + new SingleValueMappingFunction(conversionService, targetClass)); } @Override diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/SingleValueMappingFunction.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/SingleValueMappingFunction.java index 730c1df28..6dc1e00b5 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/SingleValueMappingFunction.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/SingleValueMappingFunction.java @@ -22,8 +22,9 @@ import java.util.function.BiFunction; import org.neo4j.driver.Record; import org.neo4j.driver.Value; +import org.neo4j.driver.Values; import org.neo4j.driver.types.TypeSystem; -import org.neo4j.springframework.data.core.schema.Neo4jSimpleTypes; +import org.springframework.core.convert.ConversionService; /** * Used to automatically map single valued records to a sensible Java type based on {@link Value#asObject()}. @@ -34,9 +35,13 @@ import org.neo4j.springframework.data.core.schema.Neo4jSimpleTypes; */ final class SingleValueMappingFunction implements BiFunction { + private final ConversionService conversionService; + private final Class targetClass; - SingleValueMappingFunction(Class targetClass) { + SingleValueMappingFunction(ConversionService conversionService, + Class targetClass) { + this.conversionService = conversionService; this.targetClass = targetClass; } @@ -52,6 +57,7 @@ final class SingleValueMappingFunction implements BiFunction CONVERTERS; + + static { + + List hlp = new ArrayList<>(); + hlp.add(reading(Value.class, boolean[].class, AdditionalTypes::asBooleanArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Byte.class, AdditionalTypes::asByte).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, byte.class, AdditionalTypes::asByte).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, Character.class, AdditionalTypes::asCharacter).andWriting(Values::value)); + hlp.add(reading(Value.class, char.class, AdditionalTypes::asCharacter).andWriting(Values::value)); + hlp.add(reading(Value.class, char[].class, AdditionalTypes::asCharArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Date.class, AdditionalTypes::asDate).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, double[].class, AdditionalTypes::asDoubleArray).andWriting(Values::value)); + hlp.add(new EnumConverter()); + hlp.add(reading(Value.class, Float.class, AdditionalTypes::asFloat).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, float.class, AdditionalTypes::asFloat).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, float[].class, AdditionalTypes::asFloatArray).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, Integer.class, Value::asInt).andWriting(Values::value)); + hlp.add(reading(Value.class, int.class, Value::asInt).andWriting(Values::value)); + hlp.add(reading(Value.class, int[].class, AdditionalTypes::asIntArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Locale.class, AdditionalTypes::asLocale).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, long[].class, AdditionalTypes::asLongArray).andWriting(Values::value)); + hlp.add(reading(Value.class, Short.class, AdditionalTypes::asShort).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, short.class, AdditionalTypes::asShort).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, short[].class, AdditionalTypes::asShortArray).andWriting(AdditionalTypes::value)); + hlp.add(reading(Value.class, String[].class, AdditionalTypes::asStringArray).andWriting(Values::value)); + hlp.add( + reading(Value.class, BigDecimal.class, AdditionalTypes::asBigDecimal).andWriting(AdditionalTypes::value)); + hlp.add( + reading(Value.class, BigInteger.class, AdditionalTypes::asBigInteger).andWriting(AdditionalTypes::value)); + hlp.add( + reading(Value.class, TemporalAmount.class, AdditionalTypes::asTemporalAmount).andWriting(AdditionalTypes::value)); + + CONVERTERS = Collections.unmodifiableList(hlp); + } + + static TemporalAmount asTemporalAmount(Value value) { + return new TemporalAmountAdapter().apply(value.asIsoDuration()); + } + + static Value value(TemporalAmount temporalAmount) { + return Values.value(temporalAmount); + } + + static BigDecimal asBigDecimal(Value value) { + return new BigDecimal(value.asString()); + } + + static Value value(BigDecimal bigDecimal) { + if (bigDecimal == null) { + return Values.NULL; + } + + return Values.value(bigDecimal.toString()); + } + + static BigInteger asBigInteger(Value value) { + return new BigInteger(value.asString()); + } + + static Value value(BigInteger bigInteger) { + if (bigInteger == null) { + return Values.NULL; + } + + return Values.value(bigInteger.toString()); + } + + static Byte asByte(Value value) { + byte[] bytes = value.asByteArray(); + Assert.isTrue(bytes.length == 1, "Expected a byte array with exactly 1 element."); + return bytes[0]; + } + + static Value value(Byte aByte) { + if (aByte == null) { + return Values.NULL; + } + + return Values.value(new Byte[] { aByte }); + } + + static Character asCharacter(Value value) { + char[] chars = value.asString().toCharArray(); + Assert.isTrue(chars.length == 1, "Expected a char array with exactly 1 element."); + return chars[0]; + } + + private static final String ISO_8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"; + + static Date asDate(Value value) { + try { + return new SimpleDateFormat(ISO_8601_DATE_FORMAT).parse(value.asString()); + } catch (ParseException e) { + throw new IllegalArgumentException("Could not parse " + value.asString(), e); + } + } + + static Value value(Date date) { + if (date == null) { + return Values.NULL; + } + + return Values.value(new SimpleDateFormat(ISO_8601_DATE_FORMAT).format(date)); + } + + @ReadingConverter + @WritingConverter + static class EnumConverter implements GenericConverter { + + private final Set convertiblePairs; + + EnumConverter() { + Set hlp = new HashSet<>(); + hlp.add(new ConvertiblePair(Enum.class, Value.class)); + hlp.add(new ConvertiblePair(Value.class, Enum.class)); + convertiblePairs = Collections.unmodifiableSet(hlp); + } + + @Override + public Set getConvertibleTypes() { + return this.convertiblePairs; + } + + @Override + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + Class concreteTargetType = targetType.getType(); + if (source == null) { + return concreteTargetType == Value.class ? Values.NULL : null; + } + + if (sourceType.getType() == Value.class) { + return Enum.valueOf((Class) concreteTargetType, ((Value) source).asString()); + } else { + return Values.value(((Enum) source).name()); + } + } + } + + static Float asFloat(Value value) { + return Float.parseFloat(value.asString()); + } + + static Value value(Float aFloat) { + if (aFloat == null) { + return Values.NULL; + } + + return Values.value(aFloat.toString()); + } + + static Locale asLocale(Value value) { + + return StringUtils.parseLocale(value.asString()); + } + + static Value value(Locale locale) { + if (locale == null) { + return Values.NULL; + } + + return Values.value(locale.toString()); + } + + static Short asShort(Value value) { + long val = value.asLong(); + if (val > Short.MAX_VALUE || val < Short.MIN_VALUE) { + throw new LossyCoercion(value.type().name(), "Java short"); + } + return (short) val; + } + + static Value value(Short aShort) { + if (aShort == null) { + return Values.NULL; + } + + return Values.value(aShort.longValue()); + } + + static boolean[] asBooleanArray(Value value) { + boolean[] array = new boolean[value.size()]; + int i = 0; + for (Boolean v : value.values(Value::asBoolean)) { + array[i++] = v; + } + return array; + } + + static char[] asCharArray(Value value) { + char[] array = new char[value.size()]; + int i = 0; + for (Character v : value.values(AdditionalTypes::asCharacter)) { + array[i++] = v; + } + return array; + } + + static String[] asStringArray(Value value) { + String[] array = new String[value.size()]; + return value.asList(Value::asString).toArray(array); + } + + static double[] asDoubleArray(Value value) { + double[] array = new double[value.size()]; + int i = 0; + for (double v : value.values(Value::asDouble)) { + array[i++] = v; + } + return array; + } + + static float[] asFloatArray(Value value) { + float[] array = new float[value.size()]; + int i = 0; + for (float v : value.values(AdditionalTypes::asFloat)) { + array[i++] = v; + } + return array; + } + + static Value value(float[] aFloatArray) { + if (aFloatArray == null) { + return Values.NULL; + } + + String[] values = new String[aFloatArray.length]; + int i = 0; + for (float v : aFloatArray) { + values[i++] = Float.toString(v); + } + return Values.value(values); + } + + static int[] asIntArray(Value value) { + int[] array = new int[value.size()]; + int i = 0; + for (int v : value.values(Value::asInt)) { + array[i++] = v; + } + return array; + } + + static long[] asLongArray(Value value) { + long[] array = new long[value.size()]; + int i = 0; + for (long v : value.values(Value::asLong)) { + array[i++] = v; + } + return array; + } + + static short[] asShortArray(Value value) { + short[] array = new short[value.size()]; + int i = 0; + for (short v : value.values(AdditionalTypes::asShort)) { + array[i++] = v; + } + return array; + } + + static Value value(short[] aShortArray) { + if (aShortArray == null) { + return Values.NULL; + } + + long[] values = new long[aShortArray.length]; + int i = 0; + for (short v : aShortArray) { + values[i++] = v; + } + return Values.value(values); + } + + private AdditionalTypes() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/CypherTypes.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/CypherTypes.java new file mode 100644 index 000000000..77c6d1aae --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/CypherTypes.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.core.convert; + +import static org.springframework.data.convert.ConverterBuilder.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.IsoDuration; +import org.neo4j.driver.types.Point; + +/** + * Conversions for all known Cypher types, diretly supported by the driver. + * See Working with Cypher values. + * + * @author Michael J. Simons + * @since 1.0 + */ +final class CypherTypes { + + static final List CONVERTERS; + + static { + + List hlp = new ArrayList<>(); + hlp.add(reading(Value.class, Void.class, v -> null).andWriting(v -> Values.NULL)); + hlp.add(reading(Value.class, void.class, v -> null).andWriting(v -> Values.NULL)); + hlp.add(reading(Value.class, Boolean.class, Value::asBoolean).andWriting(Values::value)); + hlp.add(reading(Value.class, boolean.class, Value::asBoolean).andWriting(Values::value)); + hlp.add(reading(Value.class, Long.class, Value::asLong).andWriting(Values::value)); + hlp.add(reading(Value.class, long.class, Value::asLong).andWriting(Values::value)); + hlp.add(reading(Value.class, Double.class, Value::asDouble).andWriting(Values::value)); + hlp.add(reading(Value.class, double.class, Value::asDouble).andWriting(Values::value)); + hlp.add(reading(Value.class, String.class, Value::asString).andWriting(Values::value)); + hlp.add(reading(Value.class, byte[].class, Value::asByteArray).andWriting(Values::value)); + hlp.add(reading(Value.class, LocalDate.class, Value::asLocalDate).andWriting(Values::value)); + hlp.add(reading(Value.class, OffsetTime.class, Value::asOffsetTime).andWriting(Values::value)); + hlp.add(reading(Value.class, LocalTime.class, Value::asLocalTime).andWriting(Values::value)); + hlp.add(reading(Value.class, ZonedDateTime.class, Value::asZonedDateTime).andWriting(Values::value)); + hlp.add(reading(Value.class, LocalDateTime.class, Value::asLocalDateTime).andWriting(Values::value)); + hlp.add(reading(Value.class, IsoDuration.class, Value::asIsoDuration).andWriting(Values::value)); + hlp.add(reading(Value.class, Point.class, Value::asPoint).andWriting(Values::value)); + + CONVERTERS = Collections.unmodifiableList(hlp); + } + + private CypherTypes() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConversions.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConversions.java new file mode 100644 index 000000000..5f84b3432 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConversions.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.core.convert; + +import static java.util.stream.Collectors.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.apiguardian.api.API; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.convert.CustomConversions; +import org.springframework.lang.Nullable; + +/** + * @author Michael J. Simons + * @soundtrack The Kleptones - A Night At The Hip-Hopera + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class Neo4jConversions extends CustomConversions { + + private static final StoreConversions STORE_CONVERSIONS; + private static final List STORE_CONVERTERS; + private static final TypeDescriptor TYPE_DESCRIPTOR_OF_VALUE = TypeDescriptor.valueOf(Value.class); + + static { + + List converters = new ArrayList<>(); + + converters.addAll(CypherTypes.CONVERTERS); + converters.addAll(AdditionalTypes.CONVERTERS); + converters.addAll(SpatialTypes.CONVERTERS); + + STORE_CONVERTERS = Collections.unmodifiableList(converters); + STORE_CONVERSIONS = StoreConversions.of(Neo4jSimpleTypes.HOLDER, STORE_CONVERTERS); + } + + /** + * Creates a {@link Neo4jConversions} object without custom converters. + */ + public Neo4jConversions() { + this(Collections.emptyList()); + } + + /** + * Creates a new {@link CustomConversions} instance registering the given converters. + * + * @param converters must not be {@literal null}. + */ + public Neo4jConversions(Collection converters) { + super(STORE_CONVERSIONS, converters); + } + + @Override + public void registerConvertersIn(ConverterRegistry conversionService) { + super.registerConvertersIn(conversionService); + + // Those can only be added at this point, as they will delegate to the target conversion service. + conversionService.addConverter(new ValueToCollectionConverter((ConversionService) conversionService)); + conversionService.addConverter(new CollectionToValueConverter((ConversionService) conversionService)); + } + + private static class ValueToCollectionConverter implements GenericConverter { + + private static final Set CONVERTIBLE_TYPES = Collections + .singleton(new ConvertiblePair(Value.class, Collection.class)); + private final ConversionService conversionService; + + ValueToCollectionConverter(ConversionService conversionService) { + this.conversionService = conversionService; + } + + @Override + public Set getConvertibleTypes() { + return CONVERTIBLE_TYPES; + } + + @Override + @Nullable + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (source == null) { + return null; + } + + Value value = (Value) source; + TypeDescriptor elementDesc = targetType.getElementTypeDescriptor(); + Collection target = CollectionFactory.createCollection(targetType.getType(), + (elementDesc != null ? elementDesc.getType() : null), value.size()); + + if (elementDesc == null) { + target.addAll(value.asList()); + } else { + value.values().forEach(sourceElement -> target.add(this.conversionService.convert(sourceElement, + TYPE_DESCRIPTOR_OF_VALUE, elementDesc))); + } + + return target; + } + } + + private static class CollectionToValueConverter implements GenericConverter { + + private static final Set CONVERTIBLE_TYPES = Collections + .singleton(new ConvertiblePair(Collection.class, Value.class)); + + private final ConversionService conversionService; + + CollectionToValueConverter(ConversionService conversionService) { + this.conversionService = conversionService; + } + + @Override + public Set getConvertibleTypes() { + return CONVERTIBLE_TYPES; + } + + @Override + @Nullable + public Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + if (source == null) { + return null; + } + Collection sourceCollection = (Collection) source; + + return Values.value((sourceCollection).stream().map(v -> conversionService + .convert(v, sourceType.elementTypeDescriptor(v), TYPE_DESCRIPTOR_OF_VALUE)) + .collect(toList())); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java new file mode 100644 index 000000000..899cbe780 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.core.convert; + +import org.neo4j.driver.Value; +import org.neo4j.driver.types.TypeSystem; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.model.ParameterValueProvider; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * This orchestrates the build-in store conversions and any additional Spring converters. + * + * @author Michael J. Simons + * @soundtrack The Kleptones - A Night At The Hip-Hopera + * @since 1.0 + */ +public interface Neo4jConverter { + + @Nullable + Object readValue(@Nullable Value value, TypeInformation type); + + @Nullable + Value writeValue(@Nullable Object value, TypeInformation type); + + /** + * Returns a {@link PersistentPropertyAccessor} that delegates to {@code targetPropertyAccessor} and applies + * all known conversions before returning a value. + * + * @param targetPropertyAccessor The property accessor to delegate to, must not be {@code null}. + * @param The type of the entity to operate on. + * @return A {@link PersistentPropertyAccessor} guaranteed to be not {@code null}. + */ + PersistentPropertyAccessor decoratePropertyAccessor(TypeSystem typeSystem, PersistentPropertyAccessor targetPropertyAccessor); + + /** + * Returns a {@link ParameterValueProvider} that delegates to {@code targetParameterValueProvider} and applies + * all known conversions before returning a value. + * + * @param targetParameterValueProvider The parameter value provider to delegate to, must not be {@code null}. + * @param The type of the entity to operate on. + * @return A {@link ParameterValueProvider} guaranteed to be not {@code null}. + */ + > ParameterValueProvider decorateParameterValueProvider( + ParameterValueProvider targetParameterValueProvider); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Neo4jSimpleTypes.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jSimpleTypes.java similarity index 59% rename from spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Neo4jSimpleTypes.java rename to spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jSimpleTypes.java index fcf8155ba..5c0b000f8 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/schema/Neo4jSimpleTypes.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jSimpleTypes.java @@ -16,8 +16,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.neo4j.springframework.data.core.schema; +package org.neo4j.springframework.data.core.convert; +import java.math.BigDecimal; +import java.math.BigInteger; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -26,15 +28,16 @@ import java.time.ZonedDateTime; import java.util.Collections; import java.util.HashSet; import java.util.Map; -import java.util.Optional; import java.util.Set; import org.apiguardian.api.API; import org.neo4j.driver.Value; import org.neo4j.driver.types.IsoDuration; -import org.neo4j.driver.types.Path; import org.neo4j.driver.types.Point; -import org.neo4j.driver.types.Relationship; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; import org.springframework.data.mapping.model.SimpleTypeHolder; /** @@ -51,29 +54,32 @@ import org.springframework.data.mapping.model.SimpleTypeHolder; @API(status = API.Status.INTERNAL, since = "1.0") public final class Neo4jSimpleTypes { - public static final Set> NEO4J_NATIVE_TYPES; + private static final Set> NEO4J_NATIVE_TYPES; static { Set> neo4jNativeTypes = new HashSet<>(); - neo4jNativeTypes.add(void.class); - neo4jNativeTypes.add(Void.class); - neo4jNativeTypes.add(Map.class); - neo4jNativeTypes.add(boolean.class); - neo4jNativeTypes.add(Boolean.class); - neo4jNativeTypes.add(long.class); - neo4jNativeTypes.add(Long.class); - neo4jNativeTypes.add(String.class); - neo4jNativeTypes.add(byte[].class); - neo4jNativeTypes.add(LocalDate.class); - neo4jNativeTypes.add(OffsetTime.class); - neo4jNativeTypes.add(LocalTime.class); - neo4jNativeTypes.add(ZonedDateTime.class); - neo4jNativeTypes.add(LocalDateTime.class); + neo4jNativeTypes.add(IsoDuration.class); + neo4jNativeTypes.add(LocalDate.class); + neo4jNativeTypes.add(LocalDateTime.class); + neo4jNativeTypes.add(LocalTime.class); + neo4jNativeTypes.add(Map.class); + neo4jNativeTypes.add(OffsetTime.class); neo4jNativeTypes.add(Point.class); - neo4jNativeTypes.add(Node.class); - neo4jNativeTypes.add(Relationship.class); - neo4jNativeTypes.add(Path.class); + neo4jNativeTypes.add(Void.class); + neo4jNativeTypes.add(ZonedDateTime.class); + neo4jNativeTypes.add(void.class); + + neo4jNativeTypes.add(BigDecimal.class); + neo4jNativeTypes.add(BigInteger.class); + + neo4jNativeTypes.add(org.springframework.data.geo.Point.class); + neo4jNativeTypes.add(GeographicPoint2d.class); + neo4jNativeTypes.add(GeographicPoint3d.class); + neo4jNativeTypes.add(CartesianPoint2d.class); + neo4jNativeTypes.add(CartesianPoint3d.class); + + neo4jNativeTypes.add(Value.class); NEO4J_NATIVE_TYPES = Collections.unmodifiableSet(neo4jNativeTypes); } @@ -81,31 +87,7 @@ public final class Neo4jSimpleTypes { /** * The simple types we support plus all the simple types recognized by Spring. */ - // TODO We need a conversion for some. - // TODO Add some special treatment for Period/Duration vs IsoDuration as well as the spatial types. - public static final SimpleTypeHolder SIMPLE_TYPE_HOLDER = new SimpleTypeHolder(NEO4J_NATIVE_TYPES, true); - - /** - * Converts the given Neo4j driver value into the designated type. - * - * @param value The value to convert. - * @param targetClass The target class to convert into - * @param The type of the target class - * @return The converted value or {@literal null} when the values has been {@literal null} - * @throws IllegalArgumentException when the value cannot be converted into an object of the given target class - */ - public static T asObject(Value value, Class targetClass) { - - Optional o = Optional.ofNullable(value).map(Value::asObject); - if (!o.isPresent()) { - return null; - } - - return o.filter(v -> targetClass.isAssignableFrom(v.getClass())) - .map(targetClass::cast) - .orElseThrow(() -> new IllegalArgumentException( - String.format("%s is not assignable from %s", targetClass.getName(), o.get().getClass().getName()))); - } + public static final SimpleTypeHolder HOLDER = new SimpleTypeHolder(NEO4J_NATIVE_TYPES, true); private Neo4jSimpleTypes() { } diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/SpatialTypes.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/SpatialTypes.java new file mode 100644 index 000000000..f51e0ab9a --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/SpatialTypes.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.core.convert; + +import static org.springframework.data.convert.ConverterBuilder.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.Coordinate; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; +import org.neo4j.springframework.data.types.Neo4jPoint; +import org.neo4j.springframework.data.types.PointBuilder; +import org.springframework.data.convert.ConverterBuilder; +import org.springframework.data.geo.Point; +import org.springframework.util.Assert; + +/** + * Mapping of spatial types. + *

+ * This replicates the behaviour of SDN+OGM. Spring Data Commons geographic points are x/y based and usually treat + * x/y as lat/long. + *

+ * Neo4j however stores x/y as long/lat when used with an Srid of 4326 or 4979 (those are geographic points). We + * take this into account with our dedicated spatial types which can be used alternatively. + *

+ * However, when converting an Spring Data Commons point to the internal value, you'll notice that we store y as x and vice versa. + * This is intentionally. We use a hardcoded WGS-84 Srid during storage, thus you'll get back your x as latitude, y as longitude, as + * described above. + *

+ * The biggest degree of freedom will come from using an attribute of type {@link org.neo4j.driver.types.Point} directly. + * This will be passed on as is. + * + * @author Michael J. Simons + * @since 1.0 + */ +final class SpatialTypes { + + static final List CONVERTERS; + + static { + + List hlp = new ArrayList<>(); + hlp.add(reading(Value.class, Point.class, SpatialTypes::asSpringDataPoint) + .andWriting(SpatialTypes::value)); + hlp.add(reading(Value.class, Point[].class, SpatialTypes::asPointArray) + .andWriting(SpatialTypes::value)); + + hlp.add(reading(Value.class, Neo4jPoint.class, SpatialTypes::asNeo4jPoint) + .andWriting(SpatialTypes::value)); + + CONVERTERS = Collections.unmodifiableList(hlp); + } + + static Neo4jPoint asNeo4jPoint(Value value) { + + org.neo4j.driver.types.Point point = value.asPoint(); + + Coordinate coordinate = new Coordinate(point.x(), point.y(), Double.isNaN(point.z()) ? null : point.z()); + return PointBuilder.withSrid(point.srid()).build(coordinate); + } + + static Value value(Neo4jPoint object) { + + if (object instanceof CartesianPoint2d) { + CartesianPoint2d point = (CartesianPoint2d) object; + return Values.point(point.getSrid(), point.getX(), point.getY()); + } else if (object instanceof CartesianPoint3d) { + CartesianPoint3d point = (CartesianPoint3d) object; + return Values.point(point.getSrid(), point.getX(), point.getY(), point.getZ()); + } else if (object instanceof GeographicPoint2d) { + GeographicPoint2d point = (GeographicPoint2d) object; + return Values.point(point.getSrid(), point.getLongitude(), point.getLatitude()); + } else if (object instanceof GeographicPoint3d) { + GeographicPoint3d point = (GeographicPoint3d) object; + return Values.point(point.getSrid(), point.getLongitude(), point.getLatitude(), + point.getHeight()); + } else { + throw new IllegalArgumentException("Unsupported point implementation: " + object.getClass()); + } + } + + static Point asSpringDataPoint(Value value) { + + org.neo4j.driver.types.Point point = value.asPoint(); + Assert.isTrue(point.srid() == 4326, "Srid must be 4326"); + + return new Point(point.y(), point.x()); + } + + static Value value(Point point) { + return Values.point(4326, point.getY(), point.getX()); + } + + static Point[] asPointArray(Value value) { + Point[] array = new Point[value.size()]; + int i = 0; + for (Point v : value.values(SpatialTypes::asSpringDataPoint)) { + array[i++] = v; + } + return array; + } + + static Value value(Point[] aPointArray) { + if (aPointArray == null) { + return Values.NULL; + } + + Value[] values = new Value[aPointArray.length]; + int i = 0; + for (Point v : aPointArray) { + values[i++] = value(v); + } + + return Values.value(values); + } + + private SpatialTypes() { + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapter.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapter.java new file mode 100644 index 000000000..7cd32212b --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapter.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.core.convert; + +import java.time.Duration; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAmount; +import java.time.temporal.TemporalUnit; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * This adapter maps a Driver or embedded based {@link TemporalAmount} to a valid Java temporal amount. It tries + * to be as specific as possible: If the amount can be reliable mapped to a {@link Period}, it returns + * a period. If only fields are present that are no estimated time unites, than it returns a {@link Duration}. + *

+ * In cases a user has used Cypher and its duration() function, i.e. like so + * CREATE (s:SomeTime {isoPeriod: duration('P13Y370M45DT25H120M')}) RETURN s + * a duration object has been created that cannot be represented by either a {@link Period} or {@link Duration}. The user + * has to map it to a plain {@link TemporalAmount} in this cases. + *
+ * The Java Driver uses a org.neo4j.driver.v1.types.IsoDuration, embedded uses + * org.neo4j.values.storable.DurationValue for representing a temporal amount, but in the end, they can be + * treated the same. + * However be aware that the temporal amount returned in that case may not be equal to the other one, only represents + * the same amount after normalization. + * + * @author Michael J. Simons + */ +final class TemporalAmountAdapter implements Function { + + private static final int PERIOD_MASK = 0b11100; + private static final int DURATION_MASK = 0b00011; + private static final TemporalUnit[] SUPPORTED_UNITS = { + ChronoUnit.YEARS, + ChronoUnit.MONTHS, + ChronoUnit.DAYS, + ChronoUnit.SECONDS, + ChronoUnit.NANOS + }; + + private static final short FIELD_YEAR = 0; + private static final short FIELD_MONTH = 1; + private static final short FIELD_DAY = 2; + private static final short FIELD_SECONDS = 3; + private static final short FIELD_NANOS = 4; + + private static final BiFunction TEMPORAL_UNIT_EXTRACTOR = (d, u) -> { + if (!d.getUnits().contains(u)) { + return 0; + } + return Math.toIntExact(d.get(u)); + }; + + @Override + public TemporalAmount apply(TemporalAmount internalTemporalAmountRepresentation) { + + int[] values = new int[SUPPORTED_UNITS.length]; + int type = 0; + for (int i = 0; i < SUPPORTED_UNITS.length; ++i) { + values[i] = TEMPORAL_UNIT_EXTRACTOR.apply(internalTemporalAmountRepresentation, SUPPORTED_UNITS[i]); + type |= (values[i] == 0) ? 0 : (0b10000 >> i); + } + + boolean couldBePeriod = couldBePeriod(type); + boolean couldBeDuration = couldBeDuration(type); + + if (couldBePeriod && !couldBeDuration) { + return Period.of(values[FIELD_YEAR], values[FIELD_MONTH], values[FIELD_DAY]).normalized(); + } else if (couldBeDuration && !couldBePeriod) { + return Duration.ofSeconds(values[FIELD_SECONDS]).plusNanos(values[FIELD_NANOS]); + } else { + return internalTemporalAmountRepresentation; + } + } + + private static boolean couldBePeriod(int type) { + return (PERIOD_MASK & type) > 0; + } + + private static boolean couldBeDuration(int type) { + return (DURATION_MASK & type) > 0; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/package-info.java new file mode 100644 index 000000000..dfc7bd9e8 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/package-info.java @@ -0,0 +1,7 @@ +/** + * Neo4j-specific conversion classes. + */ +@NonNullApi +package org.neo4j.springframework.data.core.convert; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jBinderFunction.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jBinderFunction.java index 88a77a4ba..27d4ad4f9 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jBinderFunction.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jBinderFunction.java @@ -24,6 +24,7 @@ import java.util.HashMap; import java.util.Map; import java.util.function.Function; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; import org.springframework.data.mapping.PersistentPropertyAccessor; /** @@ -35,8 +36,11 @@ final class DefaultNeo4jBinderFunction implements Function nodeDescription; - DefaultNeo4jBinderFunction(Neo4jPersistentEntity nodeDescription) { + private final Neo4jConverter converter; + + DefaultNeo4jBinderFunction(Neo4jPersistentEntity nodeDescription, Neo4jConverter converter) { this.nodeDescription = nodeDescription; + this.converter = converter; } @Override @@ -51,12 +55,7 @@ final class DefaultNeo4jBinderFunction implements Function type) { + + if (value == null || value == Values.NULL) { + return null; + } + + return conversionService.convert(value, type.getType()); + } + + @Override + public Value writeValue(@Nullable Object value, TypeInformation type) { + + if (value == null) { + return Values.NULL; + } + + return conversionService.convert(value, Value.class); + } + + @Override + public PersistentPropertyAccessor decoratePropertyAccessor(TypeSystem typeSystem, + PersistentPropertyAccessor targetPropertyAccessor) { + + return new ConvertingPropertyAccessor<>(targetPropertyAccessor, conversionService); + } + + @Override + public > ParameterValueProvider decorateParameterValueProvider( + ParameterValueProvider targetParameterValueProvider) { + + return new ParameterValueProvider() { + @Override + public Object getParameterValue(PreferredConstructor.Parameter parameter) { + + Object originalValue = targetParameterValueProvider.getParameterValue(parameter); + Assert.isInstanceOf(Value.class, originalValue, "Decorated parameters other than of type Value are not supported."); + return readValue((Value) originalValue, parameter.getType()); + } + }; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jMappingFunction.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jMappingFunction.java index 7f7fe1706..dc384f521 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jMappingFunction.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jMappingFunction.java @@ -32,13 +32,17 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.StreamSupport; import org.apache.commons.logging.LogFactory; import org.neo4j.driver.Record; import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.MapAccessor; import org.neo4j.driver.types.Node; import org.neo4j.driver.types.Relationship; import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; import org.neo4j.springframework.data.core.schema.RelationshipDescription; import org.neo4j.springframework.data.core.schema.SchemaUtils; import org.springframework.core.log.LogAccessor; @@ -49,7 +53,6 @@ import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.PreferredConstructor; import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.model.ParameterValueProvider; -import org.springframework.lang.Nullable; /** * The central logic of mapping Neo4j's {@link org.neo4j.driver.Record records} to entities based on the Spring @@ -78,44 +81,51 @@ final class DefaultNeo4jMappingFunction implements BiFunction> IS_LIST = entry -> entry.getValue() instanceof List; + private final Neo4jConverter converter; - DefaultNeo4jMappingFunction(Neo4jPersistentEntity rootNodeDescription, Neo4jMappingContext neo4jMappingContext) { + DefaultNeo4jMappingFunction(Neo4jPersistentEntity rootNodeDescription, Neo4jMappingContext neo4jMappingContext, Neo4jConverter converter) { this.rootNodeDescription = rootNodeDescription; this.mappingContext = neo4jMappingContext; + this.converter = converter; } @Override public T apply(TypeSystem typeSystem, Record record) { Map knownObjects = new ConcurrentHashMap<>(); + + // That would be the place to call a custom converter for the whole object, if any such thing would be + // available (Converter try { - Predicate isNode = v -> v.hasType(typeSystem.NODE()); - Predicate isMap = value -> value.hasType(typeSystem.MAP()); - List recordValues = record.values(); - String nodeLabel = rootNodeDescription.getPrimaryLabel(); - List nodes = recordValues.stream() - .filter(isNode) - .map(Value::asNode) - .filter(node -> node.hasLabel(nodeLabel)) - .collect(toList()); + MapAccessor queryRoot = null; + for (Value value : recordValues) { + if (value.hasType(typeSystem.NODE()) && value.asNode().hasLabel(nodeLabel)) { + if (recordValues.size() > 1) { + queryRoot = mergeRootNodeWithRecord(value.asNode(), record); + } else { + queryRoot = value.asNode(); + } + break; + } + } + if (queryRoot == null) { + for (Value value : recordValues) { + if (value.hasType(typeSystem.MAP())) { + queryRoot = value; + break; + } + } + } - // either the results will be node based - if (!nodes.isEmpty()) { - // todo find most fitting label value.asNode().labels().forEach(); - return nodes.stream().filter(node -> node.hasLabel(nodeLabel)).findFirst() - .map(node -> mergeIntoMap(node, record)) - .map(mergedAttributes -> map(mergedAttributes, rootNodeDescription, knownObjects)) - .orElseGet(() -> { - log.warn(() -> String.format("Could not find mappable nodes or relationships inside %s for %s", record, rootNodeDescription)); - return null; - }); - } else { // or it is a mostly generated result that is represented as a map - return recordValues.stream().filter(isMap) - .map(map -> map(map.asMap(), rootNodeDescription, knownObjects)).findFirst().get(); - } + if (queryRoot == null) { + log.warn(() -> String.format("Could not find mappable nodes or relationships inside %s for %s", record, + rootNodeDescription)); + return null; + } else { + return map(typeSystem, queryRoot, rootNodeDescription, knownObjects); + } } catch (Exception e) { throw new MappingException("Error mapping " + record.toString(), e); } @@ -123,18 +133,20 @@ final class DefaultNeo4jMappingFunction implements BiFunction mergeIntoMap(Node node, @Nullable Record record) { - Map mergedAttributes = new HashMap<>(node.asMap()); + private static MapAccessor mergeRootNodeWithRecord(Node node, Record record) { + Map mergedAttributes = new HashMap<>(node.size() + record.size() + 1); + mergedAttributes.put(NAME_OF_INTERNAL_ID, node.id()); - if (record != null) { - mergedAttributes.putAll(record.asMap()); - } - return mergedAttributes; + mergedAttributes.putAll(node.asMap(Function.identity())); + mergedAttributes.putAll(record.asMap(Function.identity())); + + return Values.value(mergedAttributes); } /** @@ -144,12 +156,14 @@ final class DefaultNeo4jMappingFunction implements BiFunction As in entity type * @return */ - private ET map(Map queryResult, Neo4jPersistentEntity nodeDescription, + private ET map(TypeSystem typeSystem, MapAccessor queryResult, + Neo4jPersistentEntity nodeDescription, Map knownObjects) { ET instance = instantiate(nodeDescription, queryResult); - PersistentPropertyAccessor propertyAccessor = nodeDescription.getPropertyAccessor(instance); + PersistentPropertyAccessor propertyAccessor = converter + .decoratePropertyAccessor(typeSystem, nodeDescription.getPropertyAccessor(instance)); if (nodeDescription.requiresPropertyPopulation()) { // Fill simple properties @@ -163,26 +177,30 @@ final class DefaultNeo4jMappingFunction implements BiFunction> relatedNodeDescriptionLookup = relatedLabel -> (Neo4jPersistentEntity) mappingContext.getNodeDescription(relatedLabel); nodeDescription.doWithAssociations( - populateFrom(queryResult, propertyAccessor, relationships, relatedNodeDescriptionLookup, knownObjects)); + populateFrom(typeSystem, queryResult, propertyAccessor, relationships, relatedNodeDescriptionLookup, + knownObjects)); } return instance; } - private static ET instantiate(Neo4jPersistentEntity anotherNodeDescription, Map values) { - return INSTANTIATORS.getInstantiatorFor(anotherNodeDescription).createInstance(anotherNodeDescription, - new ParameterValueProvider() { - @Override - public Object getParameterValue(PreferredConstructor.Parameter parameter) { + private ET instantiate(Neo4jPersistentEntity anotherNodeDescription, MapAccessor values) { - Neo4jPersistentProperty matchingProperty = anotherNodeDescription - .getRequiredPersistentProperty(parameter.getName()); - return extractValueOf(matchingProperty, values); - } - }); + ParameterValueProvider parameterValueProvider = new ParameterValueProvider() { + @Override + public Value getParameterValue(PreferredConstructor.Parameter parameter) { + + Neo4jPersistentProperty matchingProperty = anotherNodeDescription + .getRequiredPersistentProperty(parameter.getName()); + return extractValueOf(matchingProperty, values); + } + }; + parameterValueProvider = converter.decorateParameterValueProvider(parameterValueProvider); + return INSTANTIATORS.getInstantiatorFor(anotherNodeDescription) + .createInstance(anotherNodeDescription, parameterValueProvider); } private static PropertyHandler populateFrom( - Map queryResult, + MapAccessor queryResult, PersistentPropertyAccessor propertyAccessor, Predicate isConstructorParameter ) { @@ -191,13 +209,14 @@ final class DefaultNeo4jMappingFunction implements BiFunction populateFrom( - Map queryResult, + TypeSystem typeSystem, + MapAccessor queryResult, PersistentPropertyAccessor propertyAccessor, Collection relationships, Function> relatedNodeDescriptionLookup, @@ -216,77 +235,60 @@ final class DefaultNeo4jMappingFunction implements BiFunction targetNodeDescription = relatedNodeDescriptionLookup.apply(targetLabel); List value = new ArrayList<>(); - List list = (List) queryResult.get(SchemaUtils.generateRelatedNodesCollectionName(relationship)); + Value list = queryResult.get(SchemaUtils.generateRelatedNodesCollectionName(relationship)); // if the list is null the mapping is based on a custom query - if (list == null) { + if (list == Values.NULL) { - Predicate> containsOnlyRelationships = entry -> ((List) entry.getValue()) + Predicate isList = entry -> entry instanceof Value && typeSystem.LIST().isTypeOf(entry); + + Predicate containsOnlyRelationships = entry -> entry.asList(Function.identity()) .stream() - .allMatch(listEntry -> { - if (!(listEntry instanceof Relationship)) { - return false; - } + .allMatch(listEntry -> typeSystem.RELATIONSHIP().isTypeOf(listEntry)); - return ((Relationship) listEntry).type().equals(relationshipType); - }); - - Predicate> containsOnlyNodes = entry -> ((List) entry.getValue()).stream() - .allMatch(listEntry -> { - - if (!(listEntry instanceof Node)) { - return false; - } - - List labels = new ArrayList<>(); - (((Node) listEntry).labels()).forEach(labels::add); - return labels.contains(targetLabel); - }); + Predicate containsOnlyNodes = entry -> entry.asList(Function.identity()) + .stream() + .allMatch(listEntry -> typeSystem.NODE().isTypeOf(listEntry)); // find relationships in the result - // this is a List> - List allMatchingTypeRelationshipsInResult = queryResult.entrySet().stream() - .filter(IS_LIST.and(containsOnlyRelationships)) - .map(Map.Entry::getValue) + List allMatchingTypeRelationshipsInResult = StreamSupport + .stream(queryResult.values().spliterator(), false) + .filter(isList.and(containsOnlyRelationships)) + .flatMap(entry -> entry.asList(Value::asRelationship).stream()) + .filter(r -> r.type().equals(relationshipType)) .collect(toList()); - // this is a List> - List allNodesWithMatchingLabelInResult = queryResult.entrySet().stream() - .filter(IS_LIST.and(containsOnlyNodes)) - .map(Map.Entry::getValue) + List allNodesWithMatchingLabelInResult = StreamSupport + .stream(queryResult.values().spliterator(), false) + .filter(isList.and(containsOnlyNodes)) + .flatMap(entry -> entry.asList(Value::asNode).stream()) + .filter(n -> n.hasLabel(targetLabel)) .collect(toList()); if (allNodesWithMatchingLabelInResult.isEmpty() && allMatchingTypeRelationshipsInResult.isEmpty()) { return; } - for (Object nodeWithMatchingLabel : allNodesWithMatchingLabelInResult) { - for (Node possibleValueNode : (List) nodeWithMatchingLabel) { + for (Node possibleValueNode : allNodesWithMatchingLabelInResult) { long nodeId = possibleValueNode.id(); - for (Object relList : allMatchingTypeRelationshipsInResult) { - for (Relationship possibleRelationship : (List) relList) { - if (possibleRelationship.endNodeId() == nodeId) { - Map newPropertyMap = mergeIntoMap(possibleValueNode, null); - value.add(map(newPropertyMap, targetNodeDescription, knownObjects)); - break; - } - } + for (Relationship possibleRelationship : allMatchingTypeRelationshipsInResult) { + if (possibleRelationship.endNodeId() == nodeId) { + value.add(map(typeSystem, possibleValueNode, targetNodeDescription, knownObjects)); + break; } } } } else { - for (Object relatedEntity : list) { - Map relatedEntityValues = (Map) relatedEntity; + for (Value relatedEntity : list.asList(Function.identity())) { Neo4jPersistentProperty idProperty = targetNodeDescription.getRequiredIdProperty(); // internal (generated) id or external set Object idValue = idProperty.isInternalIdProperty() - ? relatedEntityValues.get(NAME_OF_INTERNAL_ID) - : relatedEntityValues.get(idProperty.getName()); - + ? relatedEntity.get(NAME_OF_INTERNAL_ID) + : relatedEntity.get(idProperty.getName()); Object valueEntry = knownObjects.computeIfAbsent(idValue, - (id) -> map(relatedEntityValues, targetNodeDescription, knownObjects)); + (id) -> map(typeSystem, relatedEntity, targetNodeDescription, knownObjects)); value.add(valueEntry); } @@ -304,18 +306,14 @@ final class DefaultNeo4jMappingFunction implements BiFunction propertyContainer) { + private static Value extractValueOf(Neo4jPersistentProperty property, MapAccessor propertyContainer) { if (property.isInternalIdProperty()) { - return propertyContainer.get(NAME_OF_INTERNAL_ID); + return propertyContainer instanceof Node ? + Values.value(((Node) propertyContainer).id()) : + propertyContainer.get(NAME_OF_INTERNAL_ID); } else { String graphPropertyName = property.getPropertyName(); - return getValueFor(graphPropertyName, propertyContainer); + return propertyContainer.get(graphPropertyName); } } - - private static Object getValueFor(String graphProperty, Map entity) { - - // TODO conversion, Type system - return entity.get(graphProperty); - } } diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java index 4968f4ee6..ec72a3e70 100644 --- a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java @@ -36,9 +36,11 @@ import java.util.function.Supplier; import org.apiguardian.api.API; import org.neo4j.driver.Record; import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.core.convert.Neo4jSimpleTypes; import org.neo4j.springframework.data.core.schema.IdDescription; import org.neo4j.springframework.data.core.schema.IdGenerator; -import org.neo4j.springframework.data.core.schema.Neo4jSimpleTypes; import org.neo4j.springframework.data.core.schema.NodeDescription; import org.neo4j.springframework.data.core.schema.Relationship; import org.neo4j.springframework.data.core.schema.RelationshipDescription; @@ -54,6 +56,8 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.ApplicationContext; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.context.AbstractMappingContext; @@ -88,10 +92,25 @@ public final class Neo4jMappingContext */ private final Map>, IdGenerator> fallbackIdGenerators = new ConcurrentHashMap<>(); + /** + * The converter used in this mapping context. + */ + private final Neo4jConverter converter; + private @Nullable ListableBeanFactory beanFactory; public Neo4jMappingContext() { - super.setSimpleTypeHolder(Neo4jSimpleTypes.SIMPLE_TYPE_HOLDER); + + this(new Neo4jConversions()); + } + + public Neo4jMappingContext(Neo4jConversions neo4jConversions) { + + super.setSimpleTypeHolder(Neo4jSimpleTypes.HOLDER); + + final ConfigurableConversionService conversionService = new DefaultConversionService(); + neo4jConversions.registerConvertersIn(conversionService); + this.converter = new DefaultNeo4jConverter(conversionService); } /* @@ -184,7 +203,7 @@ public final class Neo4jMappingContext public BiFunction getMappingFunctionFor(Class targetClass) { if (this.hasPersistentEntityFor(targetClass)) { Neo4jPersistentEntity neo4jPersistentEntity = this.getPersistentEntity(targetClass); - return new DefaultNeo4jMappingFunction<>(neo4jPersistentEntity, this); + return new DefaultNeo4jMappingFunction<>(neo4jPersistentEntity, this, this.converter); } return null; @@ -198,7 +217,7 @@ public final class Neo4jMappingContext } Neo4jPersistentEntity neo4jPersistentEntity = this.getPersistentEntity(sourceClass); - return new DefaultNeo4jBinderFunction(neo4jPersistentEntity); + return new DefaultNeo4jBinderFunction(neo4jPersistentEntity, converter); } private Collection computeRelationshipsOf(String primaryLabel) { diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/AbstractPoint.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/AbstractPoint.java new file mode 100644 index 000000000..4a1add354 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/AbstractPoint.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import java.util.Objects; + +/** + * Not part of public API, subject to change without notice. + * + * @author Michael J. Simons + */ +abstract class AbstractPoint implements Neo4jPoint { + + protected final Coordinate coordinate; + + private final Integer srid; + + AbstractPoint(Coordinate coordinate, Integer srid) { + this.coordinate = coordinate; + this.srid = srid; + } + + @Override + public final Integer getSrid() { + return srid; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AbstractPoint)) { + return false; + } + AbstractPoint that = (AbstractPoint) o; + return Objects.equals(coordinate, that.coordinate) && + Objects.equals(srid, that.srid); + } + + @Override + public int hashCode() { + return Objects.hash(coordinate, srid); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint2d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint2d.java new file mode 100644 index 000000000..108db32f4 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint2d.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class CartesianPoint2d extends AbstractPoint { + + static final int SRID = 7203; + + CartesianPoint2d(Coordinate coordinate) { + super(coordinate, SRID); + } + + public CartesianPoint2d(double x, double y) { + super(new Coordinate(x, y), SRID); + } + + public double getX() { + return coordinate.getX(); + } + + public double getY() { + return coordinate.getY(); + } + + @Override public String toString() { + return "CartesianPoint2d{" + + "x=" + getX() + + ", y=" + getY() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint3d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint3d.java new file mode 100644 index 000000000..598e1c658 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint3d.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class CartesianPoint3d extends AbstractPoint { + + static final int SRID = 9157; + + CartesianPoint3d(Coordinate coordinate) { + super(coordinate, SRID); + } + + public CartesianPoint3d(double x, double y, double z) { + super(new Coordinate(x, y, z), SRID); + } + + public double getX() { + return coordinate.getX(); + } + + public double getY() { + return coordinate.getY(); + } + + public Double getZ() { + return coordinate.getZ(); + } + + @Override + public String toString() { + return "CartesianPoint3d{" + + "x=" + getX() + + ", y=" + getY() + + ", z=" + getZ() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Coordinate.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Coordinate.java new file mode 100644 index 000000000..8460a74d4 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Coordinate.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import java.util.Objects; + +/** + * @author Michael J. Simons + */ +public final class Coordinate { + private final double x; + + private final double y; + + private final Double z; + + public Coordinate(double x, double y) { + this(x, y, null); + } + + public Coordinate(double x, double y, Double z) { + this.x = x; + this.y = y; + this.z = z; + } + + double getX() { + return x; + } + + double getY() { + return y; + } + + Double getZ() { + return z; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Coordinate)) { + return false; + } + Coordinate that = (Coordinate) o; + return Double.compare(that.x, x) == 0 && + Double.compare(that.y, y) == 0 && + Objects.equals(z, that.z); + } + + @Override + public int hashCode() { + return Objects.hash(x, y, z); + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint2d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint2d.java new file mode 100644 index 000000000..32260039f --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint2d.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class GeographicPoint2d extends AbstractPoint { + + GeographicPoint2d(Coordinate coordinate, Integer srid) { + super(coordinate, srid); + } + + public GeographicPoint2d(double latitude, double longitude) { + super(new Coordinate(longitude, latitude), 4326); + } + + public double getLongitude() { + return coordinate.getX(); + } + + public double getLatitude() { + return coordinate.getY(); + } + + @Override + public String toString() { + return "GeographicPoint2d{" + + "longitude=" + getLongitude() + + ", latitude=" + getLatitude() + + ", srid=" + getSrid() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint3d.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint3d.java new file mode 100644 index 000000000..0d84feb60 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint3d.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class GeographicPoint3d extends AbstractPoint { + + GeographicPoint3d(Coordinate coordinate, Integer srid) { + super(coordinate, srid); + } + + public GeographicPoint3d(double latitude, double longitude, double height) { + super(new Coordinate(longitude, latitude, height), 4979); + } + + public double getLongitude() { + return coordinate.getX(); + } + + public double getLatitude() { + return coordinate.getY(); + } + + public double getHeight() { + return coordinate.getZ(); + } + + @Override + public String toString() { + return "GeographicPoint3d{" + + "longitude=" + getLongitude() + + ", latitude=" + getLatitude() + + ", height=" + getHeight() + + ", srid=" + getSrid() + + '}'; + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Neo4jPoint.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Neo4jPoint.java new file mode 100644 index 000000000..8a5288fa3 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Neo4jPoint.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * A dedicated Neo4j point, that is aware of it's nature, either being geographic or cartesian. While you can use this + * interface as an attribute type in your domain class, you should not mix different type of points on the same attribute + * of the same label. Queries will lead to inconsistent results. Use one of the concrete implementations. + * See Spatial values. + * + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public interface Neo4jPoint { + + /** + * @return The Srid identifying the Coordinate Reference Systems (CRS) used by this point. + */ + Integer getSrid(); +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/PointBuilder.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/PointBuilder.java new file mode 100644 index 000000000..32e0f0ca4 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/PointBuilder.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import org.apiguardian.api.API; + +/** + * @author Michael J. Simons + * @since 1.0 + */ +@API(status = API.Status.STABLE, since = "1.0") +public final class PointBuilder { + + private final int srid; + + public static PointBuilder withSrid(int srid) { + return new PointBuilder(srid); + } + + private PointBuilder(int srid) { + this.srid = srid; + } + + public AbstractPoint build(Coordinate coordinate) { + + boolean is3d = coordinate.getZ() != null; + + if (srid == CartesianPoint2d.SRID || srid == CartesianPoint3d.SRID) { + return is3d ? new CartesianPoint3d(coordinate) : new CartesianPoint2d(coordinate); + } else { + return is3d ? new GeographicPoint3d(coordinate, srid) : new GeographicPoint2d(coordinate, srid); + } + } +} diff --git a/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/package-info.java b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/package-info.java new file mode 100644 index 000000000..60101da83 --- /dev/null +++ b/spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/package-info.java @@ -0,0 +1,7 @@ +/** + * Additional types provided by SDN/RX. + */ +@NonNullApi +package org.neo4j.springframework.data.types; + +import org.springframework.lang.NonNullApi; diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/SingleValueMappingFunctionTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/SingleValueMappingFunctionTest.java index 2c890e589..2c1a4d378 100644 --- a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/SingleValueMappingFunctionTest.java +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/SingleValueMappingFunctionTest.java @@ -32,6 +32,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.neo4j.driver.Record; import org.neo4j.driver.Values; import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.converter.ConverterRegistry; +import org.springframework.core.convert.support.DefaultConversionService; /** * @author Michael J. Simons @@ -43,9 +48,14 @@ class SingleValueMappingFunctionTest { private final Record record; + private final ConversionService conversionService; + SingleValueMappingFunctionTest(@Mock TypeSystem typeSystem, @Mock Record record) { this.typeSystem = typeSystem; this.record = record; + this.conversionService = new DefaultConversionService(); + new Neo4jConversions().registerConvertersIn((ConverterRegistry) this.conversionService); + } @Nested @@ -56,7 +66,7 @@ class SingleValueMappingFunctionTest { when(record.size()).thenReturn(0); - SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(String.class); + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, String.class); assertThatIllegalArgumentException().isThrownBy(() -> mappingFunction.apply(typeSystem, record)) .withMessage("Record has no elements, cannot map nothing."); } @@ -66,7 +76,7 @@ class SingleValueMappingFunctionTest { when(record.size()).thenReturn(23); - SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(String.class); + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, String.class); assertThatIllegalArgumentException().isThrownBy(() -> mappingFunction.apply(typeSystem, record)) .withMessage("Records with more than one value cannot be converted without a mapper."); } @@ -78,7 +88,7 @@ class SingleValueMappingFunctionTest { when(record.size()).thenReturn(1); when(record.get(0)).thenReturn(Values.NULL); - SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(String.class); + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, String.class); assertThat(mappingFunction.apply(typeSystem, record)).isNull(); } @@ -88,9 +98,9 @@ class SingleValueMappingFunctionTest { when(record.size()).thenReturn(1); when(record.get(0)).thenReturn(Values.value("Guten Tag.")); - SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(Period.class); - assertThatIllegalArgumentException().isThrownBy(() -> mappingFunction.apply(typeSystem, record)) - .withMessage("java.time.Period is not assignable from java.lang.String"); + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, Period.class); + assertThatExceptionOfType(ConversionFailedException.class).isThrownBy(() -> mappingFunction.apply(typeSystem, record)) + .withMessageStartingWith("Failed to convert from type [org.neo4j.driver.internal.value.StringValue] to type [java.time.Period] for value '\"Guten Tag.\"'"); } @Test @@ -101,7 +111,8 @@ class SingleValueMappingFunctionTest { when(record.size()).thenReturn(1); when(record.get(0)).thenReturn(Values.value(aDate)); - SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(LocalDate.class); + SingleValueMappingFunction mappingFunction = new SingleValueMappingFunction<>(conversionService, + LocalDate.class); assertThat(mappingFunction.apply(typeSystem, record)).isEqualTo(aDate); } } diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/SpatialTypesTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/SpatialTypesTest.java new file mode 100644 index 000000000..c65f825f8 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/SpatialTypesTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.core.convert; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; + +/** + * @author Michael J. Simons + */ +class SpatialTypesTest { + + @Test + void neo4jPointAsValueShouldWork() { + + Point point; + + point = SpatialTypes.value(new GeographicPoint2d(10, 20)).asPoint(); + assertThat(point.srid()).isEqualTo(4326); + assertThat(point.x()).isEqualTo(20.0); + assertThat(point.y()).isEqualTo(10.0); + + point = SpatialTypes.value(new CartesianPoint2d(10, 20)).asPoint(); + assertThat(point.srid()).isEqualTo(7203); + assertThat(point.x()).isEqualTo(10.0); + assertThat(point.y()).isEqualTo(20.0); + + point = SpatialTypes.value(new GeographicPoint3d(10.0, 20.0, 30)).asPoint(); + assertThat(point.srid()).isEqualTo(4979); + assertThat(point.x()).isEqualTo(20.0); + assertThat(point.y()).isEqualTo(10.0); + assertThat(point.z()).isEqualTo(30.0); + + point = SpatialTypes.value(new CartesianPoint3d(10.0, 20.0, 30)).asPoint(); + assertThat(point.srid()).isEqualTo(9157); + assertThat(point.x()).isEqualTo(10.0); + assertThat(point.y()).isEqualTo(20.0); + assertThat(point.z()).isEqualTo(30.0); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapterTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapterTest.java new file mode 100644 index 000000000..ebe39684d --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapterTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.core.convert; + +import static java.time.temporal.ChronoUnit.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.Period; + +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Values; + +/** + * @author Michael J. Simons + */ +class TemporalAmountAdapterTest { + @Test + public void internallyCreatedTypesShouldBeConvertedCorrect() { + final TemporalAmountAdapter adapter = new TemporalAmountAdapter(); + + assertThat(adapter.apply(Values.isoDuration(1, 0, 0, 0).asIsoDuration())).isEqualTo(Period.ofMonths(1)); + assertThat(adapter.apply(Values.isoDuration(1, 1, 0, 0).asIsoDuration())) + .isEqualTo(Period.ofMonths(1).plusDays(1)); + assertThat(adapter.apply(Values.isoDuration(1, 1, 1, 0).asIsoDuration())) + .isEqualTo(Values.isoDuration(1, 1, 1, 0).asIsoDuration()); + assertThat(adapter.apply(Values.isoDuration(0, 0, 120, 1).asIsoDuration())) + .isEqualTo(Duration.ofMinutes(2).plusNanos(1)); + } + + @Test + public void durationsShouldStayDurations() { + final TemporalAmountAdapter adapter = new TemporalAmountAdapter(); + + Duration duration = + MONTHS.getDuration().multipliedBy(13).plus(DAYS.getDuration().multipliedBy(32)).plusHours(25) + .plusMinutes(120); + + assertThat(adapter.apply(Values.value(duration).asIsoDuration())).isEqualTo(duration); + } + + @Test + public void periodsShouldStayPeriods() { + final TemporalAmountAdapter adapter = new TemporalAmountAdapter(); + + Period period = Period.between(LocalDate.of(2018, 11, 15), LocalDate.of(2020, 12, 24)); + + assertThat(adapter.apply(Values.value(period).asIsoDuration())).isEqualTo(period.normalized()); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/Neo4jConversionsIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/Neo4jConversionsIT.java new file mode 100644 index 000000000..7bcc425af --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/Neo4jConversionsIT.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.integration; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.DynamicTest.*; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DynamicContainer; +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.extension.ExtendWith; +import org.neo4j.driver.Session; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.integration.shared.Neo4jConversionsITBase; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.data.convert.ConverterBuilder; + +/** + * @author Michael J. Simons + */ +@ExtendWith(Neo4jExtension.class) +class Neo4jConversionsIT extends Neo4jConversionsITBase { + + private static final TypeDescriptor TYPE_DESCRIPTOR_OF_VALUE = TypeDescriptor.valueOf(Value.class); + private static final DefaultConversionService DEFAULT_CONVERSION_SERVICE = new DefaultConversionService(); + + @BeforeAll + static void prepareDefaultConversionService() { + new Neo4jConversions().registerConvertersIn(DEFAULT_CONVERSION_SERVICE); + } + + @TestFactory + @DisplayName("Objects") + Stream objects() { + Map> supportedTypes = new HashMap<>(); + supportedTypes.put("CypherTypes", CYPHER_TYPES); + supportedTypes.put("AdditionalTypes", ADDITIONAL_TYPES); + supportedTypes.put("SpatialTypes", SPATIAL_TYPES); + + return supportedTypes.entrySet().stream() + .map(types -> { + + DynamicContainer reads = DynamicContainer.dynamicContainer("read", types.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> Neo4jConversionsIT.assertRead(types.getKey(), a.getKey(), a.getValue())))); + + DynamicContainer writes = DynamicContainer.dynamicContainer("write", types.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> Neo4jConversionsIT.assertWrite(types.getKey(), a.getKey(), a.getValue())))); + + return DynamicContainer.dynamicContainer(types.getKey(), Arrays.asList(reads, writes)); + }); + } + + @TestFactory + @DisplayName("Custom conversions") + Stream customConversions() { + final DefaultConversionService customConversionService = new DefaultConversionService(); + + ConverterBuilder.ConverterAware converterAware = ConverterBuilder + .reading(Value.class, LocalDate.class, v -> { + String s = v.asString(); + switch (s) { + case "gestern": + return LocalDate.now().minusDays(1); + case "heute": + return LocalDate.now(); + case "morgen": + return LocalDate.now().plusDays(1); + default: + throw new IllegalArgumentException(); + } + }).andWriting(d -> { + if (d.isBefore(LocalDate.now())) { + return Values.value("gestern"); + } else if (d.isAfter(LocalDate.now())) { + return Values.value("morgen"); + } else { + return Values.value("heute"); + } + }); + new Neo4jConversions(converterAware.getConverters()).registerConvertersIn(customConversionService); + + return Stream.of( + dynamicTest("read", + () -> assertThat(customConversionService.convert(Values.value("gestern"), LocalDate.class)) + .isEqualTo(LocalDate.now().minusDays(1))), + dynamicTest("write", + () -> assertThat(customConversionService.convert(LocalDate.now().plusDays(1), TYPE_DESCRIPTOR_OF_VALUE)) + .isEqualTo(Values.value("morgen"))) + ); + } + + @Nested + class Primitives { + + @Test + void cypherTypes() { + boolean b = DEFAULT_CONVERSION_SERVICE.convert(Values.value(true), boolean.class); + assertThat(b).isEqualTo(true); + + long l = DEFAULT_CONVERSION_SERVICE.convert(Values.value(Long.MAX_VALUE), long.class); + assertThat(l).isEqualTo(Long.MAX_VALUE); + + double d = DEFAULT_CONVERSION_SERVICE.convert(Values.value(1.7976931348), double.class); + assertThat(d).isEqualTo(1.7976931348); + } + + @Test + void additionalTypes() { + + byte b = DEFAULT_CONVERSION_SERVICE.convert(Values.value(new byte[] { 6 }), byte.class); + assertThat(b).isEqualTo((byte) 6); + + char c = DEFAULT_CONVERSION_SERVICE.convert(Values.value("x"), char.class); + assertThat(c).isEqualTo('x'); + + float f = DEFAULT_CONVERSION_SERVICE.convert(Values.value("23.42"), float.class); + assertThat(f).isEqualTo(23.42F); + + int i = DEFAULT_CONVERSION_SERVICE.convert(Values.value(42), int.class); + assertThat(i).isEqualTo(42); + + short s = DEFAULT_CONVERSION_SERVICE.convert(Values.value((short) 127), short.class); + assertThat(s).isEqualTo((short) 127); + } + } + + static void assertRead(String label, String attribute, Object t) { + try (Session session = neo4jConnectionSupport.getDriver().session()) { + Value v = session.run("MATCH (n) WHERE labels(n) = [$label] RETURN n[$attribute] as r", + Values.parameters("label", label, "attribute", attribute)).single().get("r"); + + Object converted = DEFAULT_CONVERSION_SERVICE.convert(v, t.getClass()); + assertThat(converted).isEqualTo(t); + } + } + + static void assertWrite(String label, String attribute, Object t) { + try (Session session = neo4jConnectionSupport.getDriver().session()) { + Map parameters = new HashMap<>(); + parameters.put("label", label); + parameters.put("attribute", attribute); + parameters.put("v", DEFAULT_CONVERSION_SERVICE.convert(t, TYPE_DESCRIPTOR_OF_VALUE)); + + long cnt = session + .run("MATCH (n) WHERE labels(n) = [$label] AND n[$attribute] = $v RETURN COUNT(n) AS cnt", + parameters) + .single().get("cnt").asLong(); + assertThat(cnt).isEqualTo(1L); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java index f068ae0c9..3980826d8 100644 --- a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java @@ -392,7 +392,7 @@ class RepositoryIT { void saveSingleEntity() { PersonWithAllConstructor person = new PersonWithAllConstructor(null, "Mercury", "Freddie", "Queen", true, 1509L, - LocalDate.of(1946, 9, 15), null, emptyList(), null); + LocalDate.of(1946, 9, 15), null, Arrays.asList("b", "a"), null); PersonWithAllConstructor savedPerson = repository.save(person); try (Session session = driver.session()) { Record record = session.run("MATCH (n:PersonWithAllConstructor) WHERE n.first_name = $first_name RETURN n", @@ -401,6 +401,7 @@ class RepositoryIT { assertThat(record.containsKey("n")).isTrue(); Node node = record.get("n").asNode(); assertThat(savedPerson.getId()).isEqualTo(node.id()); + assertThat(node.get("things").asList()).containsExactly("b", "a"); } } @@ -647,6 +648,8 @@ class RepositoryIT { PersonWithAllConstructor originalPerson = repository.findById(id1).get(); originalPerson.setFirstName("Updated first name"); originalPerson.setNullable("Updated nullable field"); + assertThat(originalPerson.getThings()).isNotEmpty(); + originalPerson.setThings(emptyList()); PersonWithAllConstructor savedPerson = repository.save(originalPerson); try (Session session = driver.session()) { @@ -660,6 +663,7 @@ class RepositoryIT { assertThat(node.id()).isEqualTo(savedPerson.getId()); assertThat(node.get("first_name").asString()).isEqualTo(savedPerson.getFirstName()); assertThat(node.get("nullable").asString()).isEqualTo(savedPerson.getNullable()); + assertThat(node.get("things").asList()).isEmpty(); return null; }); diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TypeConversionIT.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TypeConversionIT.java new file mode 100644 index 000000000..dd46df25a --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TypeConversionIT.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.integration.imperative; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.DynamicTest.*; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicContainer; +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.TestFactory; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.neo4j.driver.Value; +import org.neo4j.springframework.data.config.AbstractNeo4jConfig; +import org.neo4j.springframework.data.core.convert.Neo4jConversions; +import org.neo4j.springframework.data.integration.shared.Neo4jConversionsITBase; +import org.neo4j.springframework.data.integration.shared.ThingWithAllAdditionalTypes; +import org.neo4j.springframework.data.integration.shared.ThingWithAllCypherTypes; +import org.neo4j.springframework.data.integration.shared.ThingWithAllSpatialTypes; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; +import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * @author Michael J. Simons + * @soundtrack Tool - Fear Inoculum + */ +@Neo4jIntegrationTest +class TypeConversionIT extends Neo4jConversionsITBase { + + private final Driver driver; + + private final CypherTypesRepository cypherTypesRepository; + + private final AdditionalTypesRepository additionalTypesRepository; + + private final SpatialTypesRepository spatialTypesRepository; + + private final DefaultConversionService defaultConversionService; + + @Autowired TypeConversionIT( + Driver driver, + CypherTypesRepository cypherTypesRepository, + AdditionalTypesRepository additionalTypesRepository, + SpatialTypesRepository spatialTypesRepository, + Neo4jConversions neo4jConversions + ) { + this.driver = driver; + this.cypherTypesRepository = cypherTypesRepository; + this.additionalTypesRepository = additionalTypesRepository; + this.spatialTypesRepository = spatialTypesRepository; + this.defaultConversionService = new DefaultConversionService(); + neo4jConversions.registerConvertersIn(defaultConversionService); + } + + @TestFactory + Stream conversionsShouldBeAppliedToEntities() { + + Map> supportedTypes = new HashMap<>(); + supportedTypes.put("CypherTypes", CYPHER_TYPES); + supportedTypes.put("AdditionalTypes", ADDITIONAL_TYPES); + supportedTypes.put("SpatialTypes", SPATIAL_TYPES); + + return supportedTypes.entrySet().stream() + .map(entry -> { + + Object thing; + Object copyOfThing; + switch (entry.getKey()) { + case "CypherTypes": + ThingWithAllCypherTypes hlp = cypherTypesRepository.findById(ID_OF_CYPHER_TYPES_NODE).get(); + copyOfThing = cypherTypesRepository.save(hlp.withId(null)); + thing = hlp; + break; + case "AdditionalTypes": + ThingWithAllAdditionalTypes hlp2 = additionalTypesRepository + .findById(ID_OF_ADDITIONAL_TYPES_NODE).get(); + copyOfThing = additionalTypesRepository.save(hlp2.withId(null)); + thing = hlp2; + break; + case "SpatialTypes": + ThingWithAllSpatialTypes hlp3 = spatialTypesRepository.findById(ID_OF_SPATIAL_TYPES_NODE) + .get(); + copyOfThing = spatialTypesRepository.save(hlp3.withId(null)); + thing = hlp3; + break; + default: + throw new UnsupportedOperationException("Unsupported types: " + entry.getKey()); + } + + DynamicContainer reads = DynamicContainer.dynamicContainer("read", entry.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> assertThat(ReflectionTestUtils.getField(thing, a.getKey())).isEqualTo(a.getValue())))); + + DynamicContainer writes = DynamicContainer + .dynamicContainer("write", entry.getValue().entrySet().stream() + .map(a -> dynamicTest(a.getKey(), + () -> assertWrite(copyOfThing, a.getKey(), defaultConversionService)))); + + return DynamicContainer.dynamicContainer(entry.getKey(), Arrays.asList(reads, writes)); + }); + } + + void assertWrite(Object thing, String fieldName, ConversionService conversionService) { + + long id = (long) ReflectionTestUtils.getField(thing, "id"); + Object domainValue = ReflectionTestUtils.getField(thing, fieldName); + Value driverValue = conversionService.convert(domainValue, Value.class); + + try (Session session = neo4jConnectionSupport.getDriver().session()) { + Map parameters = new HashMap<>(); + parameters.put("id", id); + parameters.put("attribute", fieldName); + parameters.put("v", driverValue); + + long cnt = session + .run("MATCH (n) WHERE id(n) = $id AND n[$attribute] = $v RETURN COUNT(n) AS cnt", + parameters) + .single().get("cnt").asLong(); + assertThat(cnt).isEqualTo(1L); + } + } + + public interface CypherTypesRepository + extends Neo4jRepository { + } + + public interface AdditionalTypesRepository + extends Neo4jRepository { + } + + public interface SpatialTypesRepository + extends Neo4jRepository { + } + + @Configuration + @EnableNeo4jRepositories(considerNestedRepositories = true) + @EnableTransactionManagement + static class Config extends AbstractNeo4jConfig { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Override + protected Collection getMappingBasePackages() { + return Collections.singletonList(ThingWithAllCypherTypes.class.getPackage().getName()); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Neo4jConversionsITBase.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Neo4jConversionsITBase.java new file mode 100644 index 000000000..398a57054 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Neo4jConversionsITBase.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.integration.shared; + +import lombok.Builder; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.Period; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.*; + +import org.junit.jupiter.api.BeforeAll; +import org.neo4j.driver.Session; +import org.neo4j.driver.Values; +import org.neo4j.springframework.data.test.Neo4jExtension; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; +import org.springframework.data.geo.Point; + +/** + * Provides some nodes spotting properties of all types we support. + * + * @author Michael J. Simons + */ +public abstract class Neo4jConversionsITBase { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + protected static final Map CYPHER_TYPES; + static { + Map hlp = new HashMap<>(); + hlp.put("aBoolean", true); + hlp.put("aLong", Long.MAX_VALUE); + hlp.put("aDouble", 1.7976931348); + hlp.put("aString", "Hallo, Cypher"); + hlp.put("aByteArray", "A thing".getBytes()); + hlp.put("aPoint", Values.point(7203, 47, 11).asPoint()); + hlp.put("aLocalDate", LocalDate.of(2015, 7, 21)); + hlp.put("anOffsetTime", OffsetTime.of(12, 31, 0, 0, ZoneOffset.ofHours(1))); + hlp.put("aLocalTime", LocalTime.of(12, 31, 14)); + hlp.put("aZoneDateTime", ZonedDateTime.of(2015, 7, 21, 21, 40, 32, 0, TimeZone.getTimeZone("America/New_York").toZoneId())); + hlp.put("aLocalDateTime", LocalDateTime.of(2015, 7, 21, 21, 0)); + hlp.put("anIsoDuration", Values.isoDuration(0, 14, 58320, 0).asObject()); + CYPHER_TYPES = Collections.unmodifiableMap(hlp); + } + + protected static final Map ADDITIONAL_TYPES; + static { + Map hlp = new HashMap<>(); + hlp.put("booleanArray", new boolean[] { true, true, false }); + hlp.put("aByte", (byte) 6); + hlp.put("aChar", 'x'); + hlp.put("charArray", new char[] { 'x', 'y', 'z' }); + hlp.put("aDate", Date.from(LocalDateTime.of(2019, 9, 21, 0, 0, 0).toInstant(ZoneOffset.UTC))); + hlp.put("aBigDecimal", BigDecimal.valueOf(Double.MAX_VALUE).multiply(BigDecimal.TEN)); + hlp.put("aBigInteger", BigInteger.valueOf(Long.MAX_VALUE).multiply(BigInteger.TEN)); + hlp.put("doubleArray", new double[] { 1.1, 2.2, 3.3 }); + hlp.put("aFloat", 23.42F); + hlp.put("floatArray", new float[] { 4.4F, 5.5F }); + hlp.put("anInt", 42); + hlp.put("intArray", new int[] { 21, 9 }); + hlp.put("aLocale", Locale.GERMANY); + hlp.put("longArray", new long[] { Long.MIN_VALUE, Long.MAX_VALUE }); + hlp.put("aShort", (short) 127); + hlp.put("shortArray", new short[] { -10, 10 }); + hlp.put("aPeriod", Period.of(23, 4, 7)); + hlp.put("aDuration", Duration.ofHours(25).plusMinutes(63).plusSeconds(65)); + hlp.put("stringArray", new String[] {"Hallo", "Welt"}); + hlp.put("listOfStrings", new ArrayList<>(Arrays.asList("Hello", "World"))); // Done on purpose, otherwise the target collection cannot be determined. + hlp.put("setOfStrings", new HashSet<>(Arrays.asList("Hallo", "wereld"))); + ADDITIONAL_TYPES = Collections.unmodifiableMap(hlp); + } + + @Builder + protected static class ParamHolder { + String name; + double latitude; + double longitude; + + Map toParameterMap() { + Map parameters = new HashMap<>(); + parameters.put("name", this.name); + parameters.put("latitude", this.latitude); + parameters.put("longitude", this.longitude); + return parameters; + } + + Point asSpringPoint() { + return new Point(latitude, longitude); + } + + GeographicPoint2d asGeo2d() { + return new GeographicPoint2d(latitude, longitude); + } + + GeographicPoint3d asGeo3d(double height) { + return new GeographicPoint3d(latitude, longitude, height); + } + } + + private static final ParamHolder NEO_HQ = ParamHolder.builder().latitude(55.612191).longitude(12.994823) + .name("Neo4j HQ").build(); + private static final ParamHolder CLARION = ParamHolder.builder().latitude(55.607726).longitude(12.994243) + .name("Clarion").build(); + private static final ParamHolder MINC = ParamHolder.builder().latitude(55.611496).longitude(12.994039).name("Minc") + .build(); + + protected static final Map SPATIAL_TYPES; + static { + Map hlp = new HashMap<>(); + hlp.put("sdnPoint", NEO_HQ.asSpringPoint()); + hlp.put("geo2d", MINC.asGeo2d()); + hlp.put("geo3d", CLARION.asGeo3d(27.0)); + hlp.put("car2d", new CartesianPoint2d(10, 20)); + hlp.put("car3d", new CartesianPoint3d(30, 40, 50)); + SPATIAL_TYPES = Collections.unmodifiableMap(hlp); + } + + protected static long ID_OF_CYPHER_TYPES_NODE; + protected static long ID_OF_ADDITIONAL_TYPES_NODE; + protected static long ID_OF_SPATIAL_TYPES_NODE; + + @BeforeAll + static void prepareData() { + + try (Session session = neo4jConnectionSupport.getDriver().session()) { + session.writeTransaction(w -> { + Map parameters; + + w.run("MATCH (n) detach delete n"); + + parameters = new HashMap<>(); + parameters.put("aByteArray", "A thing".getBytes()); + ID_OF_CYPHER_TYPES_NODE = w.run("CREATE (n:CypherTypes) SET " + + " n.aBoolean = true," + + " n.aLong = 9223372036854775807," + + " n.aDouble = 1.7976931348," + + " n.aString = 'Hallo, Cypher'," + + " n.aByteArray = $aByteArray," + + " n.aLocalDate = date('2015-07-21')," + + " n.anOffsetTime = time({ hour:12, minute:31, timezone: '+01:00' })," + + " n.aLocalTime = localtime({ hour:12, minute:31, second:14 })," + + " n.aZoneDateTime = datetime('2015-07-21T21:40:32-04[America/New_York]')," + + " n.aLocalDateTime = localdatetime('2015202T21')," + + " n.anIsoDuration = duration('P14DT16H12M')," + + " n.aPoint = point({x:47, y:11})" + + " RETURN id(n) AS id", parameters).single().get("id").asLong(); + + parameters = new HashMap<>(); + parameters.put("aByte", Values.value(new byte[] { 6 })); + ID_OF_ADDITIONAL_TYPES_NODE = w.run("CREATE (n:AdditionalTypes) SET " + + " n.booleanArray = [true, true, false]," + + " n.aByte = $aByte," + + " n.aChar = 'x'," + + " n.charArray = ['x', 'y', 'z']," + + " n.aDate = '2019-09-21T02:00:00.000+02:00'," + + " n.doubleArray = [1.1, 2.2, 3.3]," + + " n.aFloat = '23.42'," + + " n.floatArray = ['4.4', '5.5']," + + " n.anInt = 42," + + " n.intArray = [21, 9]," + + " n.aLocale = 'de_DE'," + + " n.longArray = [-9223372036854775808, 9223372036854775807]," + + " n.aShort = 127," + + " n.shortArray = [-10, 10]," + + " n.aBigDecimal = '1.79769313486231570E+309'," + + " n.aBigInteger = '92233720368547758070'," + + " n.aPeriod = duration('P23Y4M7D')," + + " n.aDuration = duration('PT26H4M5S')," + + " n.stringArray = ['Hallo', 'Welt']," + + " n.listOfStrings = ['Hello', 'World']," + + " n.setOfStrings = ['Hallo', 'wereld']" + + " RETURN id(n) AS id", parameters).single().get("id").asLong(); + + parameters = new HashMap<>(); + parameters.put("neo4j", NEO_HQ.toParameterMap()); + parameters.put("minc", MINC.toParameterMap()); + parameters.put("clarion", CLARION.toParameterMap()); + parameters.put("aByte", Values.value(new byte[] { 6 })); + ID_OF_SPATIAL_TYPES_NODE = w.run("CREATE (n:SpatialTypes) SET " + + " n.sdnPoint = point({latitude: $neo4j.latitude, longitude: $neo4j.longitude})," + + " n.geo2d = point({latitude: $minc.latitude, longitude: $minc.longitude})," + + " n.geo3d = point({latitude: $clarion.latitude, longitude: $clarion.longitude, height: 27})," + + " n.car2d = point({x: 10, y: 20})," + + " n.car3d = point({x: 30, y: 40, z: 50})" + + " RETURN id(n) AS id", parameters).single().get("id").asLong(); + w.success(); + return null; + }); + } + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithAllConstructor.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithAllConstructor.java index 73e126f4f..2840e1b2a 100644 --- a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithAllConstructor.java +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithAllConstructor.java @@ -65,7 +65,7 @@ public class PersonWithAllConstructor { private String nullable; - private final List things; + private List things; private final Point place; } diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllAdditionalTypes.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllAdditionalTypes.java new file mode 100644 index 000000000..c070d25ee --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllAdditionalTypes.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.integration.shared; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.Wither; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Duration; +import java.time.Period; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * Contains properties of all additional types. + * + * @author Michael J. Simons + */ +@Node("AdditionalTypes") +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ThingWithAllAdditionalTypes { + + @Id @GeneratedValue + @Wither + public final Long id; + + private boolean[] booleanArray; + + private byte aByte; + + private char aChar; + + private char[] charArray; + + private Date aDate; + + private BigDecimal aBigDecimal; + + private BigInteger aBigInteger; + + private double[] doubleArray; + + private float aFloat; + + private float[] floatArray; + + private int anInt; + + private int[] intArray; + + private Locale aLocale; + + private long[] longArray; + + private short aShort; + + private short[] shortArray; + + private Period aPeriod; + + private Duration aDuration; + + private String[] stringArray; + + private List listOfStrings; + + private Set setOfStrings; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllCypherTypes.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllCypherTypes.java new file mode 100644 index 000000000..653d56ca4 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllCypherTypes.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.integration.shared; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.Wither; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetTime; +import java.time.ZonedDateTime; + +import org.neo4j.driver.types.IsoDuration; +import org.neo4j.driver.types.Point; +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; + +/** + * Contains properties of all cypher types. + * + * @author Michael J. Simons + */ +@Node("CypherTypes") +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ThingWithAllCypherTypes { + + @Id @GeneratedValue + @Wither + public final Long id; + + private boolean aBoolean; + + private long aLong; + + private double aDouble; + + private String aString; + + private byte[] aByteArray; + + private LocalDate aLocalDate; + + private OffsetTime anOffsetTime; + + private LocalTime aLocalTime; + + private ZonedDateTime aZoneDateTime; + + private LocalDateTime aLocalDateTime; + + private IsoDuration anIsoDuration; + + private Point aPoint; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllSpatialTypes.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllSpatialTypes.java new file mode 100644 index 000000000..803f98c4e --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllSpatialTypes.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.integration.shared; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.experimental.Wither; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.types.CartesianPoint2d; +import org.neo4j.springframework.data.types.CartesianPoint3d; +import org.neo4j.springframework.data.types.GeographicPoint2d; +import org.neo4j.springframework.data.types.GeographicPoint3d; +import org.springframework.data.geo.Point; + +/** + * Contains properties of all spatial types. + * + * @author Michael J. Simons + */ +@Node("SpatialTypes") +@Data +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class ThingWithAllSpatialTypes { + + @Id @GeneratedValue + @Wither + public final Long id; + + private Point sdnPoint; + + private GeographicPoint2d geo2d; + + private GeographicPoint3d geo3d; + + private CartesianPoint2d car2d; + + private CartesianPoint3d car3d; +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint2dTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint2dTest.java new file mode 100644 index 000000000..44a9494d9 --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint2dTest.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * @author Michael J. Simons + */ +class GeographicPoint2dTest { + @Test + public void constructorShouldSetCorrectFields() { + + double latitude = 48.793889; + double longitude = 9.226944; + GeographicPoint2d geographicPoint2d = new GeographicPoint2d(latitude, longitude); + + assertThat(geographicPoint2d.getLatitude()).isEqualTo(latitude); + assertThat(geographicPoint2d.getLongitude()).isEqualTo(longitude); + } +} diff --git a/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint3dTest.java b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint3dTest.java new file mode 100644 index 000000000..7d547e9bd --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint3dTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.neo4j.springframework.data.types; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * @author Michael J. Simons + */ +class GeographicPoint3dTest { + @Test + public void constructorShouldSetCorrectFields() { + + double latitude = 48.793889; + double longitude = 9.226944; + double elevation = 300.0; + GeographicPoint3d geographicPoint = new GeographicPoint3d(latitude, longitude, elevation); + + assertThat(geographicPoint.getLatitude()).isEqualTo(latitude); + assertThat(geographicPoint.getLongitude()).isEqualTo(longitude); + assertThat(geographicPoint.getHeight()).isEqualTo(elevation); + } +}