From 3a8ed37321846c0935f50acfae64c9180a71a260 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Sat, 26 Sep 2020 22:22:16 +0200 Subject: [PATCH] Next stage. --- .../data/neo4j/core/convert/ConvertWith.java | 79 +++++++++++ .../neo4j/core/convert/CustomConversion.java | 72 ++++++++++ .../core/convert/CustomConversionFactory.java | 42 ++++++ .../DefaultCustomConversionFactory.java | 45 ++++++ .../DefaultNeo4jPersistentProperty.java | 46 +++--- .../core/mapping/Neo4jMappingContext.java | 74 ++++++++-- .../data/neo4j/core/mapping/Schema.java | 3 + .../data/neo4j/core/support/ConvertAs.java | 26 ---- .../data/neo4j/core/support/DateLong.java | 16 ++- .../data/neo4j/core/support/DateString.java | 131 ++++++++++++++++++ .../repository/query/CypherQueryCreator.java | 112 ++++++++------- .../repository/query/Neo4jQuerySupport.java | 18 ++- .../query/StringBasedNeo4jQuery.java | 1 + .../imperative/TypeConversionIT.java | 39 ++++-- .../shared/Neo4jConversionsITBase.java | 7 +- .../shared/ThingWithCustomTypes.java | 20 +-- 16 files changed, 583 insertions(+), 148 deletions(-) create mode 100644 src/main/java/org/springframework/data/neo4j/core/convert/ConvertWith.java create mode 100644 src/main/java/org/springframework/data/neo4j/core/convert/CustomConversion.java create mode 100644 src/main/java/org/springframework/data/neo4j/core/convert/CustomConversionFactory.java create mode 100644 src/main/java/org/springframework/data/neo4j/core/convert/DefaultCustomConversionFactory.java delete mode 100644 src/main/java/org/springframework/data/neo4j/core/support/ConvertAs.java create mode 100644 src/main/java/org/springframework/data/neo4j/core/support/DateString.java diff --git a/src/main/java/org/springframework/data/neo4j/core/convert/ConvertWith.java b/src/main/java/org/springframework/data/neo4j/core/convert/ConvertWith.java new file mode 100644 index 0000000000..d077d3e164 --- /dev/null +++ b/src/main/java/org/springframework/data/neo4j/core/convert/ConvertWith.java @@ -0,0 +1,79 @@ +/* + * Copyright 2011-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.core.convert; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.function.Function; + +import org.apiguardian.api.API; +import org.neo4j.driver.Value; + +/** + * This annotation can be used to define either custom conversions for single attributes by specifying both a writing + * and a reading converter class and if needed, a custom factory for those or it can be used to build custom meta-annotated + * annotations like {@code @org.springframework.data.neo4j.core.support.DateLong}. + * + *

Custom conversions are applied to both attributes of entities and parameters of repository methods that map to those + * attributes (which does apply to all derived queries and queries by example but not to string based queries). + * + *

Converter functions that have a default constructor don't need a dedicated factory. A dedicated factory will + * be provided with either this annotation and its values or with the meta annotated annotation, including all configuration + * available. + * + * @author Michael J. Simons + * @soundtrack Antilopen Gang - Abwasser + * @since 6.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD }) +@Inherited +@Documented +@API(status = API.Status.STABLE, since = "6.0") +public @interface ConvertWith { + + Class> writingConverter() default WritingPlaceholder.class; + + Class> readingConverter() default ReadingPlaceholder.class; + + Class converterFactory() default DefaultCustomConversionFactory.class; + + /** + * Indicates an unset writing converter. + */ + final class WritingPlaceholder implements Function { + + @Override + public Value apply(Object o) { + throw new UnsupportedOperationException(); + } + } + + /** + * Indicates an unset reading converter. + */ + final class ReadingPlaceholder implements Function { + + @Override + public Object apply(Value value) { + throw new UnsupportedOperationException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/neo4j/core/convert/CustomConversion.java b/src/main/java/org/springframework/data/neo4j/core/convert/CustomConversion.java new file mode 100644 index 0000000000..e191da3d7a --- /dev/null +++ b/src/main/java/org/springframework/data/neo4j/core/convert/CustomConversion.java @@ -0,0 +1,72 @@ +/* + * Copyright 2011-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.core.convert; + +import java.util.Objects; +import java.util.function.Function; + +import org.apiguardian.api.API; +import org.neo4j.driver.Value; +import org.springframework.util.Assert; + +/** + * This class presents the pair of writing and reading converters for a custom conversion. Bot directions are required. + * + * @author Michael J. Simons + * @soundtrack Antilopen Gang - Adrenochrom + * @since 6.0 + */ +@API(status = API.Status.STABLE, since = "6.0") +public final class CustomConversion { + + private final Function writingConverter; + + private final Function readingConverter; + + public CustomConversion(Function writingConverter, Function readingConverter) { + + Assert.notNull(writingConverter, "A writing converter is required."); + this.writingConverter = writingConverter; + Assert.notNull(readingConverter, "A reading converter is required."); + this.readingConverter = readingConverter; + } + + public Function getWritingConverter() { + return writingConverter; + } + + public Function getReadingConverter() { + return readingConverter; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CustomConversion that = (CustomConversion) o; + return writingConverter.equals(that.writingConverter) && + readingConverter.equals(that.readingConverter); + } + + @Override + public int hashCode() { + return Objects.hash(writingConverter, readingConverter); + } +} diff --git a/src/main/java/org/springframework/data/neo4j/core/convert/CustomConversionFactory.java b/src/main/java/org/springframework/data/neo4j/core/convert/CustomConversionFactory.java new file mode 100644 index 0000000000..f2dd1a26c7 --- /dev/null +++ b/src/main/java/org/springframework/data/neo4j/core/convert/CustomConversionFactory.java @@ -0,0 +1,42 @@ +/* + * Copyright 2011-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.core.convert; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; + +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.util.ReflectionUtils; + +/** + * This interface needs to be implemented to provide custom configuration for a {@link CustomConversion}. Use cases may + * be specific date formats or the like. The build method will receive the whole annotation, including all attributes. + * Classes implementing this interface must have a default constructor. + * + * @param The type of the annotation + * @author Michael J. Simons + * @soundtrack Antilopen Gang - Abwasser + * @since 6.0 + */ +public interface CustomConversionFactory { + + /** + * @param config The configuration for building the custom conversion + * @return The actual conversion + */ + CustomConversion buildConversion(A config, Class typeOfAnnotatedElement); +} diff --git a/src/main/java/org/springframework/data/neo4j/core/convert/DefaultCustomConversionFactory.java b/src/main/java/org/springframework/data/neo4j/core/convert/DefaultCustomConversionFactory.java new file mode 100644 index 0000000000..3d00cb63cb --- /dev/null +++ b/src/main/java/org/springframework/data/neo4j/core/convert/DefaultCustomConversionFactory.java @@ -0,0 +1,45 @@ +/* + * Copyright 2011-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.core.convert; + +import java.util.function.Function; + +import org.neo4j.driver.Value; +import org.springframework.beans.BeanUtils; + +/** + * @author Michael J. Simons + * @soundtrack Metallica - S&M2 + * @since 6.0 + */ +final class DefaultCustomConversionFactory implements CustomConversionFactory { + + @Override + public CustomConversion buildConversion(ConvertWith config, Class typeOfAnnotatedElement) { + + if (config.writingConverter() == ConvertWith.WritingPlaceholder.class) { + throw new IllegalArgumentException("The default custom conversion factory cannot be used with a placeholder"); + } + + if (config.readingConverter() == ConvertWith.ReadingPlaceholder.class) { + throw new IllegalArgumentException("The default custom conversion factory cannot be used with a placeholder"); + } + + Function writingConverter = BeanUtils.instantiateClass(config.writingConverter()); + Function readingConverter = BeanUtils.instantiateClass(config.readingConverter()); + return new CustomConversion(writingConverter, readingConverter); + } +} diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java index dee4d9c314..5f3a87aa3d 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jPersistentProperty.java @@ -15,12 +15,13 @@ */ package org.springframework.data.neo4j.core.mapping; +import java.lang.annotation.Annotation; import java.util.Optional; import java.util.function.Function; import org.neo4j.driver.Value; -import org.springframework.beans.BeanUtils; -import org.springframework.core.convert.converter.Converter; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentEntity; @@ -30,11 +31,14 @@ import org.springframework.data.neo4j.core.schema.Relationship; import org.springframework.data.neo4j.core.schema.RelationshipProperties; import org.springframework.data.neo4j.core.schema.TargetNode; -import org.springframework.data.neo4j.core.support.ConvertAs; +import org.springframework.data.neo4j.core.convert.ConvertWith; +import org.springframework.data.neo4j.core.convert.CustomConversionFactory; +import org.springframework.data.neo4j.core.convert.CustomConversion; import org.springframework.data.util.Lazy; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; /** * @author Michael J. Simons @@ -49,42 +53,39 @@ final class DefaultNeo4jPersistentProperty extends AnnotationBasedPersistentProp private final Neo4jMappingContext mappingContext; - private final Lazy> writingConverter; - private final Lazy> readingConverter; + private final Lazy customConversion; /** * Creates a new {@link AnnotationBasedPersistentProperty}. * - * @param property must not be {@literal null}. - * @param owner must not be {@literal null}. + * @param property must not be {@literal null}. + * @param owner must not be {@literal null}. * @param simpleTypeHolder type holder */ DefaultNeo4jPersistentProperty(Property property, PersistentEntity owner, - Neo4jMappingContext mappingContext, SimpleTypeHolder simpleTypeHolder, boolean isEntityInRelationshipWithProperties) { + Neo4jMappingContext mappingContext, SimpleTypeHolder simpleTypeHolder, + boolean isEntityInRelationshipWithProperties) { super(property, owner, simpleTypeHolder); this.isEntityInRelationshipWithProperties = isEntityInRelationshipWithProperties; + this.mappingContext = mappingContext; this.graphPropertyName = Lazy.of(this::computeGraphPropertyName); this.isAssociation = Lazy.of(() -> { Class targetType = getActualType(); - return !(simpleTypeHolder.isSimpleType(targetType) || mappingContext.hasCustomWriteTarget(targetType) || isAnnotationPresent(TargetNode.class)); + return !(simpleTypeHolder.isSimpleType(targetType) || this.mappingContext.hasCustomWriteTarget(targetType) + || isAnnotationPresent(TargetNode.class)); }); - // TODO Configuration of converters needs to be accounted for. - this.writingConverter = Lazy.of(() -> { - ConvertAs annotation = findAnnotation(ConvertAs.class); - return annotation == null ? null : - (Function) BeanUtils.instantiateClass(annotation.writingConverter()); - }); - this.readingConverter = Lazy.of(() -> { + this.customConversion = Lazy.of(() -> { + + if(this.isEntity()) { + return null; + } - ConvertAs annotation = findAnnotation(ConvertAs.class); - return annotation == null ? null : - (Function) BeanUtils.instantiateClass(annotation.readingConverter()); + return this.mappingContext.getOptionalCustomConversionsFor(getRequiredField(), getActualType()); }); - this.mappingContext = mappingContext; } @Override @@ -166,11 +167,12 @@ public boolean isEntity() { @Override public Function getOptionalWritingConverter() { - return isEntity() ? null : writingConverter.getNullable(); + return (Function) customConversion.getOptional().map(CustomConversion::getWritingConverter).orElse(null); } + @Override public Function getOptionalReadingConverter() { - return isEntity() ? null : readingConverter.getNullable(); + return (Function) customConversion.getOptional().map(CustomConversion::getReadingConverter).orElse(null); } @Override diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java b/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java index 85f5f9b3a1..9c39168e17 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java @@ -15,6 +15,8 @@ */ package org.springframework.data.neo4j.core.mapping; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Modifier; import java.util.HashMap; import java.util.Locale; @@ -32,16 +34,22 @@ import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.context.AbstractMappingContext; import org.springframework.data.mapping.model.Property; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.neo4j.core.convert.ConvertWith; +import org.springframework.data.neo4j.core.convert.CustomConversion; import org.springframework.data.neo4j.core.convert.Neo4jConversions; import org.springframework.data.neo4j.core.convert.Neo4jSimpleTypes; import org.springframework.data.neo4j.core.schema.IdGenerator; import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.convert.CustomConversionFactory; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; +import org.springframework.util.ReflectionUtils; /** * An implementation of both a {@link Schema} as well as a Neo4j version of Spring Data's @@ -60,6 +68,8 @@ public final class Neo4jMappingContext extends AbstractMappingContext>, IdGenerator> idGenerators = new ConcurrentHashMap<>(); + private final Map, CustomConversionFactory> converterFactorys = new ConcurrentHashMap<>(); + /** * The {@link NodeDescriptionStore} is basically a {@link Map} and it is used to break the dependency cycle between * this class and the {@link DefaultNeo4jEntityConverter}. @@ -200,22 +210,20 @@ public Optional> addPersistentEntity(Class type) { return super.addPersistentEntity(type); } + private T createBeanOrInstantiate(Class t) { + T idGenerator; + if (this.beanFactory == null) { + idGenerator = BeanUtils.instantiateClass(t); + } else { + idGenerator = this.beanFactory.getBeanProvider(t).getIfUnique(() -> this.beanFactory.createBean(t)); + } + return idGenerator; + } + @Override public > T getOrCreateIdGeneratorOfType(Class idGeneratorType) { - if (this.idGenerators.containsKey(idGeneratorType)) { - return (T) this.idGenerators.get(idGeneratorType); - } else { - T idGenerator; - if (this.beanFactory == null) { - idGenerator = BeanUtils.instantiateClass(idGeneratorType); - } else { - idGenerator = this.beanFactory.getBeanProvider(idGeneratorType) - .getIfUnique(() -> this.beanFactory.createBean(idGeneratorType)); - } - this.idGenerators.put(idGeneratorType, idGenerator); - return idGenerator; - } + return (T) this.idGenerators.computeIfAbsent(idGeneratorType, this::createBeanOrInstantiate); } @Override @@ -227,6 +235,46 @@ public > Optional getIdGenerator(String reference) { } } + private > T getOrCreateConverterFactoryOfType(Class converterFactoryType) { + + return (T) this.converterFactorys.computeIfAbsent(converterFactoryType, this::createBeanOrInstantiate); + } + + /** + * @param annotatedElement The annotated element + * @param actualType The actual type (The original property type if no generics were used, the + * component type for collection-like types and arrays or the value type for map properties. + * @return + */ + public CustomConversion getOptionalCustomConversionsFor(AnnotatedElement annotatedElement, Class actualType) { + + // Is the annotation present at all? + MergedAnnotation convertWith = MergedAnnotations.from(annotatedElement).get(ConvertWith.class); + if (!convertWith.isPresent()) { + return null; + } + + // Retrieve the concrete class used to provide the conversion + Class> converterFactoryClass = (Class>) convertWith + .getClass("converterFactory"); + + // Determine the concrete annotation. + // It is either the "source" annotation ConvertWith, or the root of a meta annotated. + // As the synthesized annotation is passed to the factory and annotations cannot inherit from one another + // we have to manually check if the converter takes in the default annotation or not and make sure + // we synthesize the correct type + Annotation synthesizedAnnotation; + if (ReflectionUtils.findMethod(converterFactoryClass, "buildConversion", ConvertWith.class, Class.class) + != null) { + synthesizedAnnotation = convertWith.synthesize(); + } else { + synthesizedAnnotation = convertWith.getRoot().synthesize(); + } + + CustomConversionFactory customConversionFactory = this.getOrCreateConverterFactoryOfType(converterFactoryClass); + return customConversionFactory.buildConversion(synthesizedAnnotation, actualType); + } + @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { super.setApplicationContext(applicationContext); diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/Schema.java b/src/main/java/org/springframework/data/neo4j/core/mapping/Schema.java index 779bae7880..cae72e7cfb 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/Schema.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/Schema.java @@ -15,6 +15,7 @@ */ package org.springframework.data.neo4j.core.mapping; +import java.lang.reflect.AnnotatedElement; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -25,7 +26,9 @@ import org.neo4j.driver.Record; import org.neo4j.driver.types.TypeSystem; import org.springframework.data.mapping.MappingException; +import org.springframework.data.neo4j.core.convert.CustomConversion; import org.springframework.data.neo4j.core.schema.IdGenerator; +import org.springframework.data.neo4j.core.convert.CustomConversionFactory; import org.springframework.lang.Nullable; /** diff --git a/src/main/java/org/springframework/data/neo4j/core/support/ConvertAs.java b/src/main/java/org/springframework/data/neo4j/core/support/ConvertAs.java deleted file mode 100644 index 4e9dccaeae..0000000000 --- a/src/main/java/org/springframework/data/neo4j/core/support/ConvertAs.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.springframework.data.neo4j.core.support; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.function.Function; - -import org.neo4j.driver.Value; -import org.springframework.core.convert.converter.Converter; - -/** - * @soundtrack Antilopen Gang - Abwasser - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.ANNOTATION_TYPE) -@Inherited -@Documented -public @interface ConvertAs { - - Class> writingConverter(); - - Class> readingConverter(); -} diff --git a/src/main/java/org/springframework/data/neo4j/core/support/DateLong.java b/src/main/java/org/springframework/data/neo4j/core/support/DateLong.java index da022fdb75..4f2fb36ea1 100644 --- a/src/main/java/org/springframework/data/neo4j/core/support/DateLong.java +++ b/src/main/java/org/springframework/data/neo4j/core/support/DateLong.java @@ -23,9 +23,10 @@ import java.util.Date; import java.util.function.Function; +import org.apiguardian.api.API; import org.neo4j.driver.Value; import org.neo4j.driver.Values; -import org.springframework.core.convert.converter.Converter; +import org.springframework.data.neo4j.core.convert.ConvertWith; /** * Indicates OGM to store dates as long in the database. @@ -33,15 +34,17 @@ * * @author Michael J. Simons * @soundtrack Linkin Park - One More Light Live + * @since 6.0 */ @Retention(RetentionPolicy.RUNTIME) -@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Target({ ElementType.FIELD }) @Inherited -@ConvertAs(writingConverter = DateToLongValueConverter.class, readingConverter = LongValueToDateConverter.class) +@ConvertWith(writingConverter = DateToLongValueConverter.class, readingConverter = LongValueToDateConverter.class) +@API(status = API.Status.STABLE, since = "6.0") public @interface DateLong { } -class DateToLongValueConverter implements Function { +final class DateToLongValueConverter implements Function { @Override public Value apply(Date source) { @@ -49,11 +52,10 @@ public Value apply(Date source) { } } - -class LongValueToDateConverter implements Function { +final class LongValueToDateConverter implements Function { @Override public Date apply(Value source) { - return source == null ? null : new Date(source.asLong()); + return source == null || source.isNull() ? null : new Date(source.asLong()); } } diff --git a/src/main/java/org/springframework/data/neo4j/core/support/DateString.java b/src/main/java/org/springframework/data/neo4j/core/support/DateString.java new file mode 100644 index 0000000000..745e69ee69 --- /dev/null +++ b/src/main/java/org/springframework/data/neo4j/core/support/DateString.java @@ -0,0 +1,131 @@ +/* + * Copyright 2011-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.core.support; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; +import java.util.function.Function; + +import org.apiguardian.api.API; +import org.neo4j.driver.Value; +import org.neo4j.driver.Values; +import org.springframework.core.annotation.AliasFor; +import org.springframework.data.neo4j.core.convert.ConvertWith; +import org.springframework.data.neo4j.core.convert.CustomConversion; +import org.springframework.data.neo4j.core.convert.CustomConversionFactory; + +/** + * Indicates SDN 6 to store dates as {@link String} in the database. Applicable to {@link Date} and + * {@link java.time.Instant}. + * + * @author Michael J. Simons + * @soundtrack Metallica - S&M2 + * @since 6.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD }) +@Inherited +@ConvertWith(converterFactory = DateStringCustomConversionFactory.class) +@API(status = API.Status.STABLE, since = "6.0") +public @interface DateString { + + String ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"; + + String DEFAULT_ZONE_ID = "UTC"; + + @AliasFor("format") + String value() default ISO_8601; + + @AliasFor("value") + String format() default ISO_8601; + + /** + * Some temporals like {@link java.time.Instant}, representing an instantaneous point in time cannot be formatted + * with a given {@link java.time.ZoneId}. In case you want to format an instant or similar with a default pattern, + * we assume a zone with the given id and default to {@literal UTC} which is the same assumption that the predefined + * patterns in {@link java.time.format.DateTimeFormatter} take. + * + * @return The zone id to use when applying a custom pattern to an instant temporal. + */ + String zoneId() default DEFAULT_ZONE_ID; + +} + +final class DateStringCustomConversionFactory implements CustomConversionFactory { + + @Override + public CustomConversion buildConversion(DateString config, Class typeOfAnnotatedElement) { + + if (typeOfAnnotatedElement == Date.class) { + return new CustomConversion( + new DateToStringValueConverter(config.value()), + new StringValueToDateConverter(config.value()) + ); + } else { + throw new UnsupportedOperationException( + "Other types than java.util.Date are not yet supported. Please file a ticket."); + } + } +} + +abstract class DateStringBaseConverter { + private final String format; + + DateStringBaseConverter(String format) { + this.format = format; + } + + SimpleDateFormat getFormat() { + SimpleDateFormat simpleDateFormat = new SimpleDateFormat(format); + simpleDateFormat.setTimeZone(TimeZone.getTimeZone(DateString.DEFAULT_ZONE_ID)); + return simpleDateFormat; + } +} + +final class DateToStringValueConverter extends DateStringBaseConverter implements Function { + + DateToStringValueConverter(String format) { + super(format); + } + + @Override + public Value apply(Date source) { + return source == null ? Values.NULL : Values.value(getFormat().format(source)); + } +} + +final class StringValueToDateConverter extends DateStringBaseConverter implements Function { + + StringValueToDateConverter(String format) { + super(format); + } + + @Override + public Date apply(Value source) { + try { + return source == null || source.isNull() ? null : getFormat().parse(source.asString()); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java b/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java index b39eef0054..46d08ffa7f 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java @@ -25,6 +25,7 @@ import java.util.Optional; import java.util.Queue; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -46,6 +47,7 @@ import org.neo4j.cypherdsl.core.StatementBuilder; import org.neo4j.cypherdsl.core.StatementBuilder.OngoingMatchAndReturnWithOrder; import org.neo4j.cypherdsl.core.renderer.Renderer; +import org.neo4j.driver.Value; import org.neo4j.driver.types.Point; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Range; @@ -93,7 +95,7 @@ final class CypherQueryCreator extends AbstractQueryCreator indexSupplier = new IndexSupplier(); - private final Function parameterConversion; + private final BiFunction, Object> parameterConversion; private final List boundedParameters = new ArrayList<>(); private final Pageable pagingParameter; @@ -114,7 +116,7 @@ final class CypherQueryCreator extends AbstractQueryCreator domainType, Neo4jQueryType queryType, PartTree tree, Neo4jParameterAccessor actualParameters, List includedProperties, - Function parameterConversion) { + BiFunction, Object> parameterConversion) { super(tree, actualParameters); this.mappingContext = mappingContext; @@ -257,7 +259,7 @@ protected QueryAndParameters complete(@Nullable Condition condition, Sort sort) Statement statement = createStatement(condition, sort); Map convertedParameters = this.boundedParameters.stream() - .collect(Collectors.toMap(p -> p.nameOrIndex, p -> parameterConversion.apply(p.value))); + .collect(Collectors.toMap(p -> p.nameOrIndex, p -> parameterConversion.apply(p.value, p.conversionOverride))); return new QueryAndParameters(Renderer.getDefaultRenderer().render(statement), convertedParameters); } @@ -318,75 +320,75 @@ private Statement createStatement(@Nullable Condition condition, Sort sort) { private Condition createImpl(Part part, Iterator actualParameters) { PersistentPropertyPath path = mappingContext.getPersistentPropertyPath(part.getProperty()); - Neo4jPersistentProperty persistentProperty = path.getRequiredLeafProperty(); + Neo4jPersistentProperty property = path.getRequiredLeafProperty(); boolean ignoreCase = ignoreCase(part); switch (part.getType()) { case AFTER: case GREATER_THAN: - return toCypherProperty(persistentProperty, ignoreCase) - .gt(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .gt(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case BEFORE: case LESS_THAN: - return toCypherProperty(persistentProperty, ignoreCase) - .lt(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .lt(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case BETWEEN: - return betweenCondition(persistentProperty, actualParameters, ignoreCase); + return betweenCondition(property, actualParameters, ignoreCase); case CONTAINING: - return toCypherProperty(persistentProperty, ignoreCase) - .contains(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .contains(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case ENDING_WITH: - return toCypherProperty(persistentProperty, ignoreCase) - .endsWith(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .endsWith(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case EXISTS: - return Predicates.exists(toCypherProperty(persistentProperty)); + return Predicates.exists(toCypherProperty(property)); case FALSE: - return toCypherProperty(persistentProperty, ignoreCase).isFalse(); + return toCypherProperty(property, ignoreCase).isFalse(); case GREATER_THAN_EQUAL: - return toCypherProperty(persistentProperty, ignoreCase) - .gte(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .gte(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case IN: - return toCypherProperty(persistentProperty, ignoreCase) - .in(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .in(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case IS_EMPTY: - return toCypherProperty(persistentProperty, ignoreCase).isEmpty(); + return toCypherProperty(property, ignoreCase).isEmpty(); case IS_NOT_EMPTY: - return toCypherProperty(persistentProperty, ignoreCase).isEmpty().not(); + return toCypherProperty(property, ignoreCase).isEmpty().not(); case IS_NOT_NULL: - return toCypherProperty(persistentProperty, ignoreCase).isNotNull(); + return toCypherProperty(property, ignoreCase).isNotNull(); case IS_NULL: - return toCypherProperty(persistentProperty, ignoreCase).isNull(); + return toCypherProperty(property, ignoreCase).isNull(); case LESS_THAN_EQUAL: - return toCypherProperty(persistentProperty, ignoreCase) - .lte(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .lte(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case LIKE: - return likeCondition(persistentProperty, nextRequiredParameter(actualParameters).nameOrIndex, ignoreCase); + return likeCondition(property, nextRequiredParameter(actualParameters, property).nameOrIndex, ignoreCase); case NEAR: - return createNearCondition(persistentProperty, actualParameters); + return createNearCondition(property, actualParameters); case NEGATING_SIMPLE_PROPERTY: - return toCypherProperty(persistentProperty, ignoreCase) - .isNotEqualTo(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .isNotEqualTo(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case NOT_CONTAINING: - return toCypherProperty(persistentProperty, ignoreCase) - .contains(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)).not(); + return toCypherProperty(property, ignoreCase) + .contains(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)).not(); case NOT_IN: - return toCypherProperty(persistentProperty, ignoreCase) - .in(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)).not(); + return toCypherProperty(property, ignoreCase) + .in(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)).not(); case NOT_LIKE: - return likeCondition(persistentProperty, nextRequiredParameter(actualParameters).nameOrIndex, ignoreCase).not(); + return likeCondition(property, nextRequiredParameter(actualParameters, property).nameOrIndex, ignoreCase).not(); case SIMPLE_PROPERTY: - return toCypherProperty(persistentProperty, ignoreCase) - .isEqualTo(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .isEqualTo(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case STARTING_WITH: - return toCypherProperty(persistentProperty, ignoreCase) - .startsWith(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .startsWith(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case REGEX: - return toCypherProperty(persistentProperty, ignoreCase) - .matches(toCypherParameter(nextRequiredParameter(actualParameters), ignoreCase)); + return toCypherProperty(property, ignoreCase) + .matches(toCypherParameter(nextRequiredParameter(actualParameters, property), ignoreCase)); case TRUE: - return toCypherProperty(persistentProperty, ignoreCase).isTrue(); + return toCypherProperty(property, ignoreCase).isTrue(); case WITHIN: - return createWithinCondition(persistentProperty, actualParameters); + return createWithinCondition(property, actualParameters); default: throw new IllegalArgumentException("Unsupported part type: " + part.getType()); } @@ -423,13 +425,13 @@ private Condition likeCondition(Neo4jPersistentProperty persistentProperty, Stri private Condition betweenCondition(Neo4jPersistentProperty persistentProperty, Iterator actualParameters, boolean ignoreCase) { - Parameter lowerBoundOrRange = nextRequiredParameter(actualParameters); + Parameter lowerBoundOrRange = nextRequiredParameter(actualParameters, persistentProperty); Expression property = toCypherProperty(persistentProperty, ignoreCase); if (lowerBoundOrRange.value instanceof Range) { return createRangeConditionForProperty(property, lowerBoundOrRange); } else { - Parameter upperBound = nextRequiredParameter(actualParameters); + Parameter upperBound = nextRequiredParameter(actualParameters, persistentProperty); return property.gte(toCypherParameter(lowerBoundOrRange, ignoreCase)) .and(property.lte(toCypherParameter(upperBound, ignoreCase))); } @@ -437,8 +439,8 @@ private Condition betweenCondition(Neo4jPersistentProperty persistentProperty, I private Condition createNearCondition(Neo4jPersistentProperty persistentProperty, Iterator actualParameters) { - Parameter p1 = nextRequiredParameter(actualParameters); - Optional p2 = nextOptionalParameter(actualParameters); + Parameter p1 = nextRequiredParameter(actualParameters, persistentProperty); + Optional p2 = nextOptionalParameter(actualParameters, persistentProperty); Expression referencePoint; @@ -473,7 +475,7 @@ private Condition createNearCondition(Neo4jPersistentProperty persistentProperty private Condition createWithinCondition(Neo4jPersistentProperty persistentProperty, Iterator actualParameters) { - Parameter area = nextRequiredParameter(actualParameters); + Parameter area = nextRequiredParameter(actualParameters, persistentProperty); if (area.hasValueOfType(Circle.class)) { // We don't know the CRS of the point, so we assume the same as the reference toCypherProperty Expression referencePoint = point(Cypher.mapOf("x", createCypherParameter(area.nameOrIndex + ".x", false), "y", @@ -571,14 +573,16 @@ private Expression createCypherParameter(String name, boolean addToLower) { return expression; } - private Optional nextOptionalParameter(Iterator actualParameters) { + private Optional nextOptionalParameter(Iterator actualParameters, Neo4jPersistentProperty property) { Parameter nextRequiredParameter = lastParameter.poll(); if (nextRequiredParameter != null) { return Optional.of(nextRequiredParameter); } else if (formalParameters.hasNext()) { final Neo4jQueryMethod.Neo4jParameter parameter = formalParameters.next(); - Parameter boundedParameter = new Parameter(parameter.getName().orElseGet(indexSupplier), actualParameters.next()); + + Parameter boundedParameter = new Parameter(parameter.getName().orElseGet(indexSupplier), + actualParameters.next(), property.getOptionalWritingConverter()); boundedParameters.add(boundedParameter); return Optional.of(boundedParameter); } else { @@ -586,7 +590,7 @@ private Optional nextOptionalParameter(Iterator actualParamet } } - private Parameter nextRequiredParameter(Iterator actualParameters) { + private Parameter nextRequiredParameter(Iterator actualParameters, Neo4jPersistentProperty property) { Parameter nextRequiredParameter = lastParameter.poll(); if (nextRequiredParameter != null) { @@ -596,7 +600,8 @@ private Parameter nextRequiredParameter(Iterator actualParameters) { throw new IllegalStateException("Not enough formal, bindable parameters for parts"); } final Neo4jQueryMethod.Neo4jParameter parameter = formalParameters.next(); - Parameter boundedParameter = new Parameter(parameter.getName().orElseGet(indexSupplier), actualParameters.next()); + Parameter boundedParameter = new Parameter(parameter.getName().orElseGet(indexSupplier), + actualParameters.next(), property.getOptionalWritingConverter()); boundedParameters.add(boundedParameter); return boundedParameter; } @@ -608,9 +613,12 @@ static class Parameter { final Object value; - Parameter(String nameOrIndex, Object value) { + final @Nullable Function conversionOverride; + + Parameter(String nameOrIndex, Object value, @Nullable Function conversionOverride) { this.nameOrIndex = nameOrIndex; this.value = value; + this.conversionOverride = conversionOverride; } boolean hasValueOfType(Class type) { diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java b/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java index 84aa3d8b6a..71588b07d5 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java @@ -22,9 +22,11 @@ import java.util.List; import java.util.Map; import java.util.function.BiFunction; +import java.util.function.Function; 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.TypeSystem; import org.springframework.core.log.LogAccessor; @@ -39,6 +41,7 @@ import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.util.ClassTypeInformation; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -121,6 +124,17 @@ protected final List getInputProperties(final ResultProcessor resultProc * @return A parameter that fits the place holders of a generated query */ final Object convertParameter(Object parameter) { + return this.convertParameter(parameter, null); + } + + /** + * Converts parameter as needed by the query generated, which is not covered by standard conversion services. + * + * @param parameter The parameter to fit into the generated query. + * @param conversionOverride Passed to the entity converter if present. + * @return A parameter that fits the place holders of a generated query + */ + final Object convertParameter(Object parameter, @Nullable Function conversionOverride) { if (parameter == null) { // According to https://neo4j.com/docs/cypher-manual/current/syntax/working-with-null/#cypher-null-intro @@ -147,9 +161,9 @@ final Object convertParameter(Object parameter) { return convertBoundingBox((BoundingBox) parameter); } - // Good hook to check the NodeManager whether the thing is an entity and we replace the value with a known id. + // TODO Good hook to check the NodeManager whether the thing is an entity and we replace the value with a known id. return mappingContext.getEntityConverter().writeValueFromProperty(parameter, - ClassTypeInformation.from(parameter.getClass())); + ClassTypeInformation.from(parameter.getClass()), conversionOverride); } private Map convertRange(Range range) { diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java b/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java index 66e5cad1a6..569bcb8e81 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java @@ -26,6 +26,7 @@ import org.springframework.data.mapping.MappingException; import org.springframework.data.neo4j.core.Neo4jOperations; import org.springframework.data.neo4j.core.PreparedQuery; +import org.springframework.data.neo4j.core.convert.CustomConversion; import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; diff --git a/src/test/java/org/springframework/data/neo4j/integration/imperative/TypeConversionIT.java b/src/test/java/org/springframework/data/neo4j/integration/imperative/TypeConversionIT.java index fd81dcfe51..30606d44cd 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/imperative/TypeConversionIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/imperative/TypeConversionIT.java @@ -19,10 +19,16 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import java.lang.reflect.Field; +import java.text.SimpleDateFormat; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -40,17 +46,15 @@ import org.neo4j.driver.Session; import org.neo4j.driver.Value; import org.neo4j.driver.Values; -import org.springframework.beans.BeanUtils; 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.converter.Converter; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.mapping.MappingException; import org.springframework.data.neo4j.config.AbstractNeo4jConfig; import org.springframework.data.neo4j.core.convert.Neo4jConversions; -import org.springframework.data.neo4j.core.support.ConvertAs; +import org.springframework.data.neo4j.core.convert.ConvertWith; import org.springframework.data.neo4j.integration.shared.Neo4jConversionsITBase; import org.springframework.data.neo4j.integration.shared.ThingWithAllAdditionalTypes; import org.springframework.data.neo4j.integration.shared.ThingWithAllCypherTypes; @@ -63,7 +67,6 @@ import org.springframework.data.neo4j.test.Neo4jIntegrationTest; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; /** @@ -167,13 +170,15 @@ void assertWrite(Object thing, String fieldName, ConversionService conversionSer Object domainValue = ReflectionTestUtils.getField(thing, fieldName); Field field = ReflectionUtils.findField(thing.getClass(), fieldName); - Optional annotation = AnnotationUtils.findAnnotation(field, ConvertAs.class); - Function conversion = annotation.map(ConvertAs::writingConverter) - .map(BeanUtils::instantiateClass) - .map(c -> ((Converter)c)) - .map(c -> (Function) c::convert) - .orElseGet(() -> o -> conversionService.convert(o, Value.class)); - + Optional annotation = AnnotationUtils.findAnnotation(field, ConvertWith.class); + Function conversion; + if (fieldName.equals("dateAsLong")) { + conversion = o -> Values.value(((Date) o).getTime()); + } else if (fieldName.equals("dateAsString")) { + conversion = o -> Values.value(new SimpleDateFormat("yyyy-MM-dd").format(o)); + } else { + conversion = o -> conversionService.convert(o, Value.class); + } Value driverValue; if (domainValue != null && Collection.class.isAssignableFrom(domainValue.getClass())) { Collection sourceCollection = (Collection) domainValue; @@ -219,6 +224,13 @@ void relatedIdsShouldBeConverted(@Autowired ConvertedIDsRepository repository) { Assertions.assertThat(repository.findById(savedThing.getAnotherThing().getId())).isPresent(); } + @Test + void parametersTargetingConvertedAttributesMustBeConverted(@Autowired CustomTypesRepository repository) { + + assertThat(repository.findAllByDateAsString(Date.from(ZonedDateTime.of(2013, 5, 6, + 12, 0, 0, 0, ZoneId.of("Europe/Berlin")).toInstant().truncatedTo(ChronoUnit.DAYS)))).hasSizeGreaterThan(0); + } + public interface ConvertedIDsRepository extends Neo4jRepository {} public interface CypherTypesRepository extends Neo4jRepository {} @@ -229,7 +241,10 @@ public interface SpatialTypesRepository extends Neo4jRepository {} - public interface CustomTypesRepository extends Neo4jRepository {} + public interface CustomTypesRepository extends Neo4jRepository { + + List findAllByDateAsString(Date theDate); + } @Configuration @EnableNeo4jRepositories(considerNestedRepositories = true) diff --git a/src/test/java/org/springframework/data/neo4j/integration/shared/Neo4jConversionsITBase.java b/src/test/java/org/springframework/data/neo4j/integration/shared/Neo4jConversionsITBase.java index a03f45a5a9..534f93d2f8 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/shared/Neo4jConversionsITBase.java +++ b/src/test/java/org/springframework/data/neo4j/integration/shared/Neo4jConversionsITBase.java @@ -32,6 +32,8 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -175,6 +177,8 @@ GeographicPoint3d asGeo3d(double height) { hlp.put("customType", ThingWithCustomTypes.CustomType.of("ABCD")); hlp.put("dateAsLong", Date.from(ZonedDateTime.of(2020, 9, 21, 12, 0, 0, 0, ZoneId.of("Europe/Berlin")).toInstant())); + hlp.put("dateAsString", Date.from(ZonedDateTime.of(2013, 5, 6, + 12, 0, 0, 0, ZoneId.of("Europe/Berlin")).toInstant().truncatedTo(ChronoUnit.DAYS))); CUSTOM_TYPES = Collections.unmodifiableMap(hlp); } @@ -242,8 +246,9 @@ static void prepareData() { parameters = new HashMap<>(); parameters.put("customType", "ABCD"); parameters.put("dateAsLong", 1600682400000L); + parameters.put("dateAsString", "2013-05-06"); ID_OF_CUSTOM_TYPE_NODE = w - .run("CREATE (n:CustomTypes) SET n.customType = $customType, n.dateAsLong = $dateAsLong RETURN id(n) AS id", parameters) + .run("CREATE (n:CustomTypes) SET n.customType = $customType, n.dateAsLong = $dateAsLong, n.dateAsString = $dateAsString RETURN id(n) AS id", parameters) .single().get("id").asLong(); w.commit(); return null; diff --git a/src/test/java/org/springframework/data/neo4j/integration/shared/ThingWithCustomTypes.java b/src/test/java/org/springframework/data/neo4j/integration/shared/ThingWithCustomTypes.java index ca2fe9ed92..b5a6a94fbe 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/shared/ThingWithCustomTypes.java +++ b/src/test/java/org/springframework/data/neo4j/integration/shared/ThingWithCustomTypes.java @@ -15,8 +15,6 @@ */ package org.springframework.data.neo4j.integration.shared; -import java.time.ZoneId; -import java.time.ZonedDateTime; import java.util.Date; import java.util.HashSet; import java.util.Objects; @@ -31,6 +29,7 @@ import org.springframework.data.neo4j.core.schema.Id; import org.springframework.data.neo4j.core.schema.Node; import org.springframework.data.neo4j.core.support.DateLong; +import org.springframework.data.neo4j.core.support.DateString; /** * @author Gerrit Meier @@ -46,33 +45,28 @@ public class ThingWithCustomTypes { @DateLong private Date dateAsLong; - public ThingWithCustomTypes(Long id, CustomType customType, Date dateAsLong) { + @DateString("yyyy-MM-dd") + private Date dateAsString; + + public ThingWithCustomTypes(Long id, CustomType customType, Date dateAsLong, Date dateAsString) { this.id = id; this.customType = customType; this.dateAsLong = dateAsLong; + this.dateAsString = dateAsString; } public ThingWithCustomTypes withId(Long newId) { - return new ThingWithCustomTypes(newId, this.customType, this.dateAsLong); + return new ThingWithCustomTypes(newId, this.customType, this.dateAsLong, this.dateAsString); } public CustomType getCustomType() { return customType; } - public Date getDateAsLong() { - return dateAsLong; - } - public void setDateAsLong(Date dateAsLong) { this.dateAsLong = dateAsLong; } - public static void main(String...a) { - System.out.println(Date.from(ZonedDateTime.of(2020, 9, 21, - 12, 0, 0, 0, ZoneId.of("Europe/Berlin")).toInstant()).getTime()); - } - /** * Custom type to convert */