Skip to content

Commit 865946a

Browse files
GH-2324 - Improve handling of 0-valued IsoDuration.
Introduce a dedicated converter in parallel to the already existing adapter, that is able to augment the returned type in case a zero based `IsoDuration` has been received with the actual target type. Closes #2324.
1 parent 37a2f12 commit 865946a

File tree

7 files changed

+174
-49
lines changed

7 files changed

+174
-49
lines changed

src/main/java/org/springframework/data/neo4j/core/convert/AdditionalTypes.java

+1-11
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import java.time.ZoneId;
2626
import java.time.ZoneOffset;
2727
import java.time.format.DateTimeFormatter;
28-
import java.time.temporal.TemporalAmount;
2928
import java.util.ArrayList;
3029
import java.util.Arrays;
3130
import java.util.Collections;
@@ -93,8 +92,7 @@ final class AdditionalTypes {
9392
hlp.add(ConverterBuilder.reading(Value.class, String[].class, AdditionalTypes::asStringArray).andWriting(Values::value));
9493
hlp.add(ConverterBuilder.reading(Value.class, BigDecimal.class, AdditionalTypes::asBigDecimal).andWriting(AdditionalTypes::value));
9594
hlp.add(ConverterBuilder.reading(Value.class, BigInteger.class, AdditionalTypes::asBigInteger).andWriting(AdditionalTypes::value));
96-
hlp.add(ConverterBuilder.reading(Value.class, TemporalAmount.class, AdditionalTypes::asTemporalAmount)
97-
.andWriting(AdditionalTypes::value));
95+
hlp.add(new TemporalAmountConverter());
9896
hlp.add(ConverterBuilder.reading(Value.class, Instant.class, AdditionalTypes::asInstant).andWriting(AdditionalTypes::value));
9997
hlp.add(ConverterBuilder.reading(Value.class, UUID.class, AdditionalTypes::asUUID).andWriting(AdditionalTypes::value));
10098
hlp.add(ConverterBuilder.reading(Value.class, URL.class, AdditionalTypes::asURL).andWriting(AdditionalTypes::value));
@@ -179,14 +177,6 @@ static Value value(Instant instant) {
179177
return Values.value(instant.atOffset(ZoneOffset.UTC));
180178
}
181179

182-
static TemporalAmount asTemporalAmount(Value value) {
183-
return new TemporalAmountAdapter().apply(value.asIsoDuration());
184-
}
185-
186-
static Value value(TemporalAmount temporalAmount) {
187-
return Values.value(temporalAmount);
188-
}
189-
190180
static BigDecimal asBigDecimal(Value value) {
191181
return new BigDecimal(value.asString());
192182
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2011-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.data.neo4j.core.convert;
17+
18+
import java.time.Duration;
19+
import java.time.Period;
20+
import java.time.temporal.TemporalAmount;
21+
import java.util.Arrays;
22+
import java.util.Collections;
23+
import java.util.HashSet;
24+
import java.util.Set;
25+
26+
import org.neo4j.driver.Value;
27+
import org.neo4j.driver.Values;
28+
import org.neo4j.driver.types.IsoDuration;
29+
import org.springframework.core.convert.TypeDescriptor;
30+
import org.springframework.core.convert.converter.GenericConverter;
31+
32+
/**
33+
* This generic converter has been introduce to augment the {@link TemporalAmountAdapter} with the type information passed
34+
* to a generic converter to make some educated guesses whether an {@link org.neo4j.driver.types.IsoDuration} of {@literal 0}
35+
* should be possibly treated as {@link java.time.Period} or {@link java.time.Duration}.
36+
*
37+
* @author Michael J. Simons
38+
* @soundtrack Motörhead - Bomber
39+
*/
40+
final class TemporalAmountConverter implements GenericConverter {
41+
42+
private final TemporalAmountAdapter adapter = new TemporalAmountAdapter();
43+
private final Set<ConvertiblePair> convertibleTypes = Collections.unmodifiableSet(
44+
new HashSet<>(Arrays.asList(
45+
new ConvertiblePair(Value.class, TemporalAmount.class),
46+
new ConvertiblePair(TemporalAmount.class, Value.class)
47+
)));
48+
49+
@Override
50+
public Set<ConvertiblePair> getConvertibleTypes() {
51+
return convertibleTypes;
52+
}
53+
54+
@Override
55+
public Object convert(Object value, TypeDescriptor sourceType, TypeDescriptor targetType) {
56+
57+
if (TemporalAmount.class.isAssignableFrom(sourceType.getType())) {
58+
return Values.value(value);
59+
}
60+
61+
boolean valueIsLiteralNullOrNullValue = value == null || value == Values.NULL;
62+
Object convertedValue = valueIsLiteralNullOrNullValue ? null : adapter.apply(((Value) value).asIsoDuration());
63+
64+
if (convertedValue instanceof IsoDuration && isZero((IsoDuration) convertedValue)) {
65+
if (Period.class.isAssignableFrom(targetType.getType())) {
66+
return Period.of(0, 0, 0);
67+
} else if (Duration.class.isAssignableFrom(targetType.getType())) {
68+
return Duration.ZERO;
69+
}
70+
}
71+
return convertedValue;
72+
}
73+
74+
/**
75+
* @param isoDuration The duration to check whether it's {@literal 0} or not.
76+
* @return True if there are only temporal units in that duration with a value of {@literal 0}.
77+
*/
78+
private static boolean isZero(IsoDuration isoDuration) {
79+
80+
return isoDuration.months() == 0L && isoDuration.days() == 0L &&
81+
isoDuration.seconds() == 0L && isoDuration.nanoseconds() == 0L;
82+
}
83+
}

src/test/java/org/springframework/data/neo4j/core/convert/TemporalAmountAdapterTest.java

+17-9
Original file line numberDiff line numberDiff line change
@@ -24,39 +24,47 @@
2424

2525
import org.junit.jupiter.api.Test;
2626
import org.neo4j.driver.Values;
27+
import org.neo4j.driver.types.IsoDuration;
2728

2829
/**
2930
* @author Michael J. Simons
3031
*/
3132
class TemporalAmountAdapterTest {
33+
34+
private final TemporalAmountAdapter underTest = new TemporalAmountAdapter();
35+
3236
@Test
3337
public void internallyCreatedTypesShouldBeConvertedCorrect() {
34-
final TemporalAmountAdapter adapter = new TemporalAmountAdapter();
3538

36-
assertThat(adapter.apply(Values.isoDuration(1, 0, 0, 0).asIsoDuration())).isEqualTo(Period.ofMonths(1));
37-
assertThat(adapter.apply(Values.isoDuration(1, 1, 0, 0).asIsoDuration())).isEqualTo(Period.ofMonths(1).plusDays(1));
38-
assertThat(adapter.apply(Values.isoDuration(1, 1, 1, 0).asIsoDuration()))
39+
assertThat(underTest.apply(Values.isoDuration(1, 0, 0, 0).asIsoDuration())).isEqualTo(Period.ofMonths(1));
40+
assertThat(underTest.apply(Values.isoDuration(1, 1, 0, 0).asIsoDuration())).isEqualTo(Period.ofMonths(1).plusDays(1));
41+
assertThat(underTest.apply(Values.isoDuration(1, 1, 1, 0).asIsoDuration()))
3942
.isEqualTo(Values.isoDuration(1, 1, 1, 0).asIsoDuration());
40-
assertThat(adapter.apply(Values.isoDuration(0, 0, 120, 1).asIsoDuration()))
43+
assertThat(underTest.apply(Values.isoDuration(0, 0, 120, 1).asIsoDuration()))
4144
.isEqualTo(Duration.ofMinutes(2).plusNanos(1));
4245
}
4346

4447
@Test
4548
public void durationsShouldStayDurations() {
46-
final TemporalAmountAdapter adapter = new TemporalAmountAdapter();
4749

4850
Duration duration = ChronoUnit.MONTHS.getDuration().multipliedBy(13).plus(ChronoUnit.DAYS.getDuration().multipliedBy(32)).plusHours(25)
4951
.plusMinutes(120);
5052

51-
assertThat(adapter.apply(Values.value(duration).asIsoDuration())).isEqualTo(duration);
53+
assertThat(underTest.apply(Values.value(duration).asIsoDuration())).isEqualTo(duration);
5254
}
5355

5456
@Test
5557
public void periodsShouldStayPeriods() {
56-
final TemporalAmountAdapter adapter = new TemporalAmountAdapter();
5758

5859
Period period = Period.between(LocalDate.of(2018, 11, 15), LocalDate.of(2020, 12, 24));
5960

60-
assertThat(adapter.apply(Values.value(period).asIsoDuration())).isEqualTo(period.normalized());
61+
assertThat(underTest.apply(Values.value(period).asIsoDuration())).isEqualTo(period.normalized());
62+
}
63+
64+
@Test // GH-2324
65+
public void zeroDurationShouldReturnTheIsoDuration() {
66+
67+
IsoDuration zeroDuration = Values.isoDuration(0, 0, 0, 0).asIsoDuration();
68+
assertThat(underTest.apply(zeroDuration)).isSameAs(zeroDuration);
6169
}
6270
}

src/test/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jConversionServiceTest.java

+34-1
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@
1515
*/
1616
package org.springframework.data.neo4j.core.mapping;
1717

18+
import static org.assertj.core.api.Assertions.assertThat;
1819
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
1920

21+
import java.time.Duration;
2022
import java.time.LocalDate;
23+
import java.time.Period;
2124
import java.time.format.DateTimeParseException;
25+
import java.time.temporal.TemporalAmount;
2226
import java.util.Date;
2327

2428
import org.junit.jupiter.api.Nested;
@@ -43,6 +47,35 @@ class DefaultNeo4jConversionServiceTest {
4347

4448
@Nested
4549
class Reads {
50+
51+
@Test // GH-2324
52+
void shouldDealWith0IsoDurationsAsPeriods() {
53+
Value zeroDuration = Values.isoDuration(0, 0, 0, 0);
54+
55+
Period period = (Period) defaultNeo4jEntityAccessor.readValue(zeroDuration, ClassTypeInformation.from(Period.class), null);
56+
assertThat(period.isZero()).isTrue();
57+
}
58+
59+
@Test // GH-2324
60+
void shouldDealWith0IsoDurationsAsDurations() {
61+
Value zeroDuration = Values.isoDuration(0, 0, 0, 0);
62+
63+
Duration duration = (Duration) defaultNeo4jEntityAccessor.readValue(zeroDuration, ClassTypeInformation.from(Duration.class), null);
64+
assertThat(duration).isZero();
65+
}
66+
67+
@Test // GH-2324
68+
void shouldDealWithNullTemporalValueOnRead() {
69+
Duration duration = (Duration) defaultNeo4jEntityAccessor.readValue(null, ClassTypeInformation.from(Duration.class), null);
70+
assertThat(duration).isNull();
71+
}
72+
73+
@Test // GH-2324
74+
void shouldDealWithNullTemporalValueOnWrite() {
75+
Value value = defaultNeo4jEntityAccessor.writeValue(null, ClassTypeInformation.from(TemporalAmount.class), null);
76+
assertThat(value).isNull();
77+
}
78+
4679
@Test
4780
void shouldCatchConversionErrors() {
4881
Value value = Values.value("Das funktioniert nicht.");
@@ -65,7 +98,7 @@ void shouldCatchUncoercibleErrors() {
6598
}
6699

67100
@Test
68-
void shouldCatchUncoerfcibleErrors() {
101+
void shouldCatchCoercibleErrors() {
69102
Value value = Values.value("Das funktioniert nicht.");
70103

71104
assertThatExceptionOfType(TypeMismatchDataAccessException.class).isThrownBy(

src/test/java/org/springframework/data/neo4j/integration/shared/common/ThingWithAllCypherTypes.java

+6
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
import lombok.Data;
2222
import lombok.With;
2323

24+
import java.time.Duration;
2425
import java.time.LocalDate;
2526
import java.time.LocalDateTime;
2627
import java.time.LocalTime;
2728
import java.time.OffsetTime;
29+
import java.time.Period;
2830
import java.time.ZonedDateTime;
2931

3032
import org.neo4j.driver.types.IsoDuration;
@@ -69,4 +71,8 @@ public class ThingWithAllCypherTypes {
6971
private IsoDuration anIsoDuration;
7072

7173
private Point aPoint;
74+
75+
private Period aZeroPeriod;
76+
77+
private Duration aZeroDuration;
7278
}

src/test/java/org/springframework/data/neo4j/integration/shared/conversion/Neo4jConversionsITBase.java

+29-28
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ public abstract class Neo4jConversionsITBase {
123123
hlp.put("listOfDoubles", Arrays.asList(1.0));
124124
hlp.put("aTimeZone", TimeZone.getTimeZone("America/Los_Angeles"));
125125
hlp.put("aZoneId", ZoneId.of("America/New_York"));
126+
hlp.put("aZeroPeriod", Period.of(0, 0, 0));
127+
hlp.put("aZeroDuration", Duration.ZERO);
126128
ADDITIONAL_TYPES = Collections.unmodifiableMap(hlp);
127129
}
128130

@@ -202,37 +204,36 @@ static void prepareData() {
202204

203205
parameters = new HashMap<>();
204206
parameters.put("aByteArray", "A thing".getBytes());
205-
ID_OF_CYPHER_TYPES_NODE = w.run("CREATE (n:CypherTypes) SET " + " n.aBoolean = true,"
206-
+ " n.aLong = 9223372036854775807," + " n.aDouble = 1.7976931348," + " n.aString = 'Hallo, Cypher',"
207-
+ " n.aByteArray = $aByteArray," + " n.aLocalDate = date('2015-07-21'),"
208-
+ " n.anOffsetTime = time({ hour:12, minute:31, timezone: '+01:00' }),"
209-
+ " n.aLocalTime = localtime({ hour:12, minute:31, second:14 }),"
210-
+ " n.aZoneDateTime = datetime('2015-07-21T21:40:32-04[America/New_York]'),"
211-
+ " n.aLocalDateTime = localdatetime('2015202T21')," + " n.anIsoDuration = duration('P14DT16H12M'),"
212-
+ " n.aPoint = point({x:47, y:11})" + " RETURN id(n) AS id", parameters).single().get("id").asLong();
207+
ID_OF_CYPHER_TYPES_NODE = w.run("CREATE (n:CypherTypes) SET n.aBoolean = true,\n"
208+
+ " n.aLong = 9223372036854775807, n.aDouble = 1.7976931348, n.aString = 'Hallo, Cypher',\n"
209+
+ " n.aByteArray = $aByteArray, n.aLocalDate = date('2015-07-21'),\n"
210+
+ " n.anOffsetTime = time({ hour:12, minute:31, timezone: '+01:00' }),\n"
211+
+ " n.aLocalTime = localtime({ hour:12, minute:31, second:14 }),\n"
212+
+ " n.aZoneDateTime = datetime('2015-07-21T21:40:32-04[America/New_York]'),\n"
213+
+ " n.aLocalDateTime = localdatetime('2015202T21'), n.anIsoDuration = duration('P14DT16H12M'),\n"
214+
+ " n.aPoint = point({x:47, y:11})\n"
215+
+ " RETURN id(n) AS id", parameters).single().get("id").asLong();
213216

214217
parameters = new HashMap<>();
215218
parameters.put("aByte", Values.value(new byte[] { 6 }));
216-
ID_OF_ADDITIONAL_TYPES_NODE = w
217-
.run("CREATE (n:AdditionalTypes) SET " + " n.booleanArray = [true, true, false]," + " n.aByte = $aByte,"
218-
+ " n.aChar = 'x'," + " n.charArray = ['x', 'y', 'z']," + " n.aDate = '2019-09-21T13:23:11Z',"
219-
+ " n.doubleArray = [1.1, 2.2, 3.3]," + " n.aFloat = '23.42'," + " n.floatArray = ['4.4', '5.5'],"
220-
+ " n.anInt = 42," + " n.intArray = [21, 9]," + " n.aLocale = 'de_DE',"
221-
+ " n.longArray = [-9223372036854775808, 9223372036854775807]," + " n.aShort = 127,"
222-
+ " n.shortArray = [-10, 10]," + " n.aBigDecimal = '1.79769313486231570E+309',"
223-
+ " n.aBigInteger = '92233720368547758070'," + " n.aPeriod = duration('P23Y4M7D'),"
224-
+ " n.aDuration = duration('PT26H4M5S')," + " n.stringArray = ['Hallo', 'Welt'],"
225-
+ " n.listOfStrings = ['Hello', 'World']," + " n.setOfStrings = ['Hallo', 'Welt'],"
226-
+ " n.anInstant = datetime('2019-09-26T20:34:23Z'),"
227-
+ " n.aUUID = 'd4ec9208-4b17-4ec7-a709-19a5e53865a8'," + " n.listOfDoubles = [1.0],"
228-
+ " n.aURL = 'https://www.test.com',"
229-
+ " n.aURI = 'urn:isbn:9783864905254',"
230-
+ " n.anEnum = 'TheUsualMisfit'," + " n.anArrayOfEnums = ['ValueA', 'ValueB'],"
231-
+ " n.aCollectionOfEnums = ['ValueC', 'TheUsualMisfit'],"
232-
+ " n.aTimeZone = 'America/Los_Angeles', "
233-
+ " n.aZoneId = 'America/New_York'"
234-
+ " RETURN id(n) AS id", parameters)
235-
.single().get("id").asLong();
219+
ID_OF_ADDITIONAL_TYPES_NODE = w.run("CREATE (n:AdditionalTypes) SET n.booleanArray = [true, true, false], n.aByte = $aByte,\n"
220+
+ " n.aChar = 'x', n.charArray = ['x', 'y', 'z'], n.aDate = '2019-09-21T13:23:11Z',\n"
221+
+ " n.doubleArray = [1.1, 2.2, 3.3], n.aFloat = '23.42', n.floatArray = ['4.4', '5.5'],\n"
222+
+ " n.anInt = 42, n.intArray = [21, 9], n.aLocale = 'de_DE',\n"
223+
+ " n.longArray = [-9223372036854775808, 9223372036854775807], n.aShort = 127,\n"
224+
+ " n.shortArray = [-10, 10], n.aBigDecimal = '1.79769313486231570E+309',\n"
225+
+ " n.aBigInteger = '92233720368547758070', n.aPeriod = duration('P23Y4M7D'),\n"
226+
+ " n.aDuration = duration('PT26H4M5S'), n.stringArray = ['Hallo', 'Welt'],\n"
227+
+ " n.listOfStrings = ['Hello', 'World'], n.setOfStrings = ['Hallo', 'Welt'],\n"
228+
+ " n.anInstant = datetime('2019-09-26T20:34:23Z'),\n"
229+
+ " n.aUUID = 'd4ec9208-4b17-4ec7-a709-19a5e53865a8', n.listOfDoubles = [1.0],\n"
230+
+ " n.aURL = 'https://www.test.com',\n"
231+
+ " n.aURI = 'urn:isbn:9783864905254',\n"
232+
+ " n.anEnum = 'TheUsualMisfit', n.anArrayOfEnums = ['ValueA', 'ValueB'],\n"
233+
+ " n.aCollectionOfEnums = ['ValueC', 'TheUsualMisfit'],\n"
234+
+ " n.aTimeZone = 'America/Los_Angeles', \n"
235+
+ " n.aZoneId = 'America/New_York', n.aZeroPeriod = duration('PT0S'), n.aZeroDuration = duration('PT0S')\n"
236+
+ " RETURN id(n) AS id", parameters).single().get("id").asLong();
236237

237238
parameters = new HashMap<>();
238239
parameters.put("neo4j", NEO_HQ.toParameterMap());

src/test/java/org/springframework/data/neo4j/integration/shared/conversion/ThingWithAllAdditionalTypes.java

+4
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,8 @@ enum SomeEnum {
118118
private TimeZone aTimeZone;
119119

120120
private ZoneId aZoneId;
121+
122+
private Period aZeroPeriod;
123+
124+
private Duration aZeroDuration;
121125
}

0 commit comments

Comments
 (0)