From cb54971a655ffb59b10eea7a2ca0764245e31d03 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Wed, 11 Sep 2019 13:37:03 +0200 Subject: [PATCH] Add support for CustomConversions. This adds `Neo4jConversions`, an implementation of Spring Datas `CustomConversions`. A default instance is provided through the configuration support and also in the automatic configuration of Spring Boot. The `Neo4jConversions` is than used to create an internal `ConversionService` used by a `Neo4jConverter`, that provides methods for reading and writing values and also for decorating property accessors and parameter value providers. While writing the conversion, it turned out that we could postpone converting from drivers `Value` as late as possible. Thus all possible user provided conversions need to be conversions from `org.neo4j.driver.Value` to the desired type and back. This closes #23. --- etc/jqassistant/structure.adoc | 7 +- .../data/config/AbstractNeo4jConfig.java | 21 ++ .../config/AbstractReactiveNeo4jConfig.java | 25 +- .../config/Neo4jConfigurationSupport.java | 41 +-- .../ReactiveNeo4jConfigurationSupport.java | 133 ------- .../data/core/DefaultNeo4jClient.java | 10 +- .../data/core/DefaultReactiveNeo4jClient.java | 10 +- .../data/core/SingleValueMappingFunction.java | 12 +- .../data/core/convert/AdditionalTypes.java | 331 ++++++++++++++++++ .../data/core/convert/CypherTypes.java | 74 ++++ .../data/core/convert/Neo4jConversions.java | 156 +++++++++ .../data/core/convert/Neo4jConverter.java | 64 ++++ .../{schema => convert}/Neo4jSimpleTypes.java | 76 ++-- .../data/core/convert/SpatialTypes.java | 142 ++++++++ .../core/convert/TemporalAmountAdapter.java | 101 ++++++ .../data/core/convert/package-info.java | 7 + .../mapping/DefaultNeo4jBinderFunction.java | 13 +- .../core/mapping/DefaultNeo4jConverter.java | 92 +++++ .../mapping/DefaultNeo4jMappingFunction.java | 204 ++++++----- .../core/mapping/Neo4jMappingContext.java | 27 +- .../data/types/AbstractPoint.java | 61 ++++ .../data/types/CartesianPoint2d.java | 54 +++ .../data/types/CartesianPoint3d.java | 60 ++++ .../data/types/Coordinate.java | 73 ++++ .../data/types/GeographicPoint2d.java | 54 +++ .../data/types/GeographicPoint3d.java | 59 ++++ .../data/types/Neo4jPoint.java | 39 +++ .../data/types/PointBuilder.java | 50 +++ .../data/types/package-info.java | 7 + .../core/SingleValueMappingFunctionTest.java | 25 +- .../data/core/convert/SpatialTypesTest.java | 62 ++++ .../convert/TemporalAmountAdapterTest.java | 67 ++++ .../data/integration/Neo4jConversionsIT.java | 184 ++++++++++ .../integration/imperative/RepositoryIT.java | 6 +- .../imperative/TypeConversionIT.java | 181 ++++++++++ .../shared/Neo4jConversionsITBase.java | 217 ++++++++++++ .../shared/PersonWithAllConstructor.java | 2 +- .../shared/ThingWithAllAdditionalTypes.java | 96 +++++ .../shared/ThingWithAllCypherTypes.java | 77 ++++ .../shared/ThingWithAllSpatialTypes.java | 60 ++++ .../data/types/GeographicPoint2dTest.java | 39 +++ .../data/types/GeographicPoint3dTest.java | 41 +++ 42 files changed, 2719 insertions(+), 341 deletions(-) delete mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/config/ReactiveNeo4jConfigurationSupport.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/AdditionalTypes.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/CypherTypes.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConversions.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java rename spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/{schema => convert}/Neo4jSimpleTypes.java (59%) create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/SpatialTypes.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapter.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/convert/package-info.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/AbstractPoint.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint2d.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/CartesianPoint3d.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Coordinate.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint2d.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/GeographicPoint3d.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/Neo4jPoint.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/PointBuilder.java create mode 100644 spring-data-neo4j/src/main/java/org/neo4j/springframework/data/types/package-info.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/SpatialTypesTest.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/core/convert/TemporalAmountAdapterTest.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/Neo4jConversionsIT.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/imperative/TypeConversionIT.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/Neo4jConversionsITBase.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllAdditionalTypes.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllCypherTypes.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/integration/shared/ThingWithAllSpatialTypes.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint2dTest.java create mode 100644 spring-data-neo4j/src/test/java/org/neo4j/springframework/data/types/GeographicPoint3dTest.java 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); + } +}