Skip to content

Commit 7f8cfd0

Browse files
GH-2348 - Use default values for primitive attributes when Graph properties are unset.
This changes the behaviour in regards of default values for primitives attributes of domain objects: They have well known default values on the JVM and we should use them, even if the property is non-existent in the graph (materializing as literal null). People migrating from SDN+OGM rely on the behaviour. This change does not apply to attributes assigned via constructor arguments. Constructor argument don't have default values (in Java) and we try to avoid guessing as much as possible. In addition, it would also clash with actually possible default values in Kotlin data classes. The change cannot be done in `DefaultNeo4jConversionService` as originally planned, as the conversion service is of course unaware about the target of a converted value. This fixes #2348.
1 parent 520bc7a commit 7f8cfd0

File tree

5 files changed

+141
-80
lines changed

5 files changed

+141
-80
lines changed

src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java

+16-6
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.neo4j.driver.types.Type;
4444
import org.neo4j.driver.types.TypeSystem;
4545
import org.springframework.core.CollectionFactory;
46+
import org.springframework.core.KotlinDetector;
4647
import org.springframework.data.mapping.AssociationHandler;
4748
import org.springframework.data.mapping.MappingException;
4849
import org.springframework.data.mapping.PersistentPropertyAccessor;
@@ -52,6 +53,7 @@
5253
import org.springframework.data.mapping.model.ParameterValueProvider;
5354
import org.springframework.data.neo4j.core.convert.Neo4jConversionService;
5455
import org.springframework.data.neo4j.core.schema.TargetNode;
56+
import org.springframework.data.util.ReflectionUtils;
5557
import org.springframework.data.util.TypeInformation;
5658
import org.springframework.lang.NonNull;
5759
import org.springframework.lang.Nullable;
@@ -107,7 +109,7 @@ public <R> R read(Class<R> targetType, MapAccessor mapAccessor) {
107109
try {
108110
return map(queryRoot, queryRoot, rootNodeDescription, new HashSet<>());
109111
} catch (Exception e) {
110-
throw new MappingException("Error mapping " + mapAccessor.toString(), e);
112+
throw new MappingException("Error mapping " + mapAccessor, e);
111113
}
112114
}
113115

@@ -267,6 +269,7 @@ private <ET> ET map(MapAccessor queryResult, MapAccessor allValues, Neo4jPersist
267269
Collection<RelationshipDescription> relationships = nodeDescription
268270
.getRelationshipsInHierarchy(includeAllFields);
269271

272+
boolean isKotlinType = KotlinDetector.isKotlinType(concreteNodeDescription.getType());
270273
ET instance = instantiate(concreteNodeDescription, queryResult, allValues, relationships,
271274
nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity);
272275

@@ -279,7 +282,7 @@ private <ET> ET map(MapAccessor queryResult, MapAccessor allValues, Neo4jPersist
279282
Predicate<Neo4jPersistentProperty> isConstructorParameter = concreteNodeDescription
280283
.getPersistenceConstructor()::isConstructorParameter;
281284
PropertyHandler<Neo4jPersistentProperty> handler = populateFrom(queryResult, propertyAccessor,
282-
isConstructorParameter, nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity);
285+
isConstructorParameter, nodeDescriptionAndLabels.getDynamicLabels(), lastMappedEntity, isKotlinType);
283286
concreteNodeDescription.doWithProperties(handler);
284287

285288
// in a cyclic graph / with bidirectional relationships, we could end up in a state in which we
@@ -393,22 +396,24 @@ public Object getParameterValue(PreferredConstructor.Parameter parameter) {
393396

394397
private PropertyHandler<Neo4jPersistentProperty> populateFrom(MapAccessor queryResult,
395398
PersistentPropertyAccessor<?> propertyAccessor, Predicate<Neo4jPersistentProperty> isConstructorParameter,
396-
Collection<String> surplusLabels, Object targetNode) {
399+
Collection<String> surplusLabels, Object targetNode, boolean ownerIsKotlinType) {
397400
return property -> {
398401
if (isConstructorParameter.test(property)) {
399402
return;
400403
}
401404

405+
TypeInformation<?> typeInformation = property.getTypeInformation();
402406
if (property.isDynamicLabels()) {
403407
propertyAccessor.setProperty(property,
404-
createDynamicLabelsProperty(property.getTypeInformation(), surplusLabels));
408+
createDynamicLabelsProperty(typeInformation, surplusLabels));
405409
} else if (property.isAnnotationPresent(TargetNode.class)) {
406410
if (queryResult instanceof Relationship) {
407411
propertyAccessor.setProperty(property, targetNode);
408412
}
409413
} else {
410-
propertyAccessor.setProperty(property,
411-
conversionService.readValue(extractValueOf(property, queryResult), property.getTypeInformation(), property.getOptionalReadingConverter()));
414+
Object value = conversionService.readValue(extractValueOf(property, queryResult), typeInformation, property.getOptionalReadingConverter());
415+
Class<?> rawType = typeInformation.getType();
416+
propertyAccessor.setProperty(property, getValueOrDefault(ownerIsKotlinType, rawType, value));
412417
}
413418
};
414419
}
@@ -427,6 +432,11 @@ private AssociationHandler<Neo4jPersistentProperty> populateFrom(MapAccessor que
427432
};
428433
}
429434

435+
private static Object getValueOrDefault(boolean ownerIsKotlinType, Class<?> rawType, @Nullable Object value) {
436+
437+
return value == null && !ownerIsKotlinType && rawType.isPrimitive() ? ReflectionUtils.getPrimitiveDefault(rawType) : value;
438+
}
439+
430440
private Optional<Object> createInstanceOfRelationships(Neo4jPersistentProperty persistentProperty, MapAccessor values,
431441
MapAccessor allValues, RelationshipDescription relationshipDescription) {
432442

src/test/java/org/springframework/data/neo4j/integration/conversion_imperative/TypeConversionIT.java

+48-31
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import static org.assertj.core.api.Assertions.assertThat;
1919
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
2020

21-
import java.lang.reflect.Field;
2221
import java.text.SimpleDateFormat;
2322
import java.time.ZoneId;
2423
import java.time.ZonedDateTime;
@@ -41,7 +40,6 @@
4140
import org.junit.jupiter.api.DynamicTest;
4241
import org.junit.jupiter.api.Test;
4342
import org.junit.jupiter.api.TestFactory;
44-
import org.junit.platform.commons.util.AnnotationUtils;
4543
import org.neo4j.driver.Driver;
4644
import org.neo4j.driver.Session;
4745
import org.neo4j.driver.Value;
@@ -53,22 +51,20 @@
5351
import org.springframework.core.convert.support.DefaultConversionService;
5452
import org.springframework.data.mapping.MappingException;
5553
import org.springframework.data.neo4j.config.AbstractNeo4jConfig;
56-
import org.springframework.data.neo4j.core.convert.ConvertWith;
5754
import org.springframework.data.neo4j.core.convert.Neo4jConversions;
55+
import org.springframework.data.neo4j.core.Neo4jTemplate;
56+
import org.springframework.data.neo4j.integration.shared.common.ThingWithAllCypherTypes2;
5857
import org.springframework.data.neo4j.integration.shared.conversion.Neo4jConversionsITBase;
5958
import org.springframework.data.neo4j.integration.shared.conversion.ThingWithAllAdditionalTypes;
6059
import org.springframework.data.neo4j.integration.shared.common.ThingWithAllCypherTypes;
6160
import org.springframework.data.neo4j.integration.shared.common.ThingWithAllSpatialTypes;
62-
import org.springframework.data.neo4j.integration.shared.conversion.ThingWithCompositeProperties;
6361
import org.springframework.data.neo4j.integration.shared.conversion.ThingWithCustomTypes;
64-
import org.springframework.data.neo4j.integration.shared.common.ThingWithNonExistingPrimitives;
6562
import org.springframework.data.neo4j.integration.shared.common.ThingWithUUIDID;
6663
import org.springframework.data.neo4j.repository.Neo4jRepository;
6764
import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories;
6865
import org.springframework.data.neo4j.test.Neo4jIntegrationTest;
6966
import org.springframework.test.util.ReflectionTestUtils;
7067
import org.springframework.transaction.annotation.EnableTransactionManagement;
71-
import org.springframework.util.ReflectionUtils;
7268

7369
/**
7470
* @author Michael J. Simons
@@ -78,9 +74,7 @@
7874
@Neo4jIntegrationTest
7975
class TypeConversionIT extends Neo4jConversionsITBase {
8076

81-
private final Driver driver;
82-
83-
@Autowired CypherTypesRepository cypherTypesRepository;
77+
private final CypherTypesRepository cypherTypesRepository;
8478

8579
private final AdditionalTypesRepository additionalTypesRepository;
8680

@@ -90,11 +84,10 @@ class TypeConversionIT extends Neo4jConversionsITBase {
9084

9185
private final DefaultConversionService defaultConversionService;
9286

93-
@Autowired TypeConversionIT(Driver driver, CypherTypesRepository cypherTypesRepository,
87+
@Autowired TypeConversionIT(CypherTypesRepository cypherTypesRepository,
9488
AdditionalTypesRepository additionalTypesRepository, SpatialTypesRepository spatialTypesRepository,
9589
CustomTypesRepository customTypesRepository,
9690
Neo4jConversions neo4jConversions) {
97-
this.driver = driver;
9891
this.cypherTypesRepository = cypherTypesRepository;
9992
this.additionalTypesRepository = additionalTypesRepository;
10093
this.spatialTypesRepository = spatialTypesRepository;
@@ -104,14 +97,20 @@ class TypeConversionIT extends Neo4jConversionsITBase {
10497
}
10598

10699
@Test
107-
void thereShallBeNoDefaultValuesForNonExistingAttributes(@Autowired NonExistingPrimitivesRepository repository) {
100+
void thereShallBeNoDefaultValuesForNonExistingAttributes() {
101+
102+
Long id;
103+
try (Session session = neo4jConnectionSupport.getDriver().session()) {
104+
105+
id = session.writeTransaction(tx -> tx.run("CREATE (n:CypherTypes) RETURN id(n)").single().get(0).asLong());
106+
}
108107

109108
assertThatExceptionOfType(MappingException.class)
110-
.isThrownBy(() -> repository.findById(ID_OF_NON_EXISTING_PRIMITIVES_NODE))
109+
.isThrownBy(() -> cypherTypesRepository.findById(id))
111110
.withMessageMatching(
112-
"Error mapping Record<\\{n: \\{__internalNeo4jId__: \\d+, id: NULL, someBoolean: NULL, __nodeLabels__: \\[\"NonExistingPrimitives\"\\]\\}\\}>")
113-
.withStackTraceContaining("unboxBoolean")
114-
.withRootCauseInstanceOf(NullPointerException.class);
111+
"Error mapping Record<\\{n: \\{__internalNeo4jId__: \\d+, aBoolean: NULL, aString: NULL, aLong: NULL, anOffsetTime: NULL, aLocalDateTime: NULL, aDouble: NULL, aByteArray: NULL, aPoint: NULL, aZeroDuration: NULL, aZoneDateTime: NULL, __nodeLabels__: \\[\"CypherTypes\"], aLocalDate: NULL, aZeroPeriod: NULL, anIsoDuration: NULL, aLocalTime: NULL, id: NULL}}>")
112+
.withStackTraceContaining("Illegal arguments for constructor")
113+
.withRootCauseInstanceOf(IllegalArgumentException.class);
115114
}
116115

117116
@TestFactory
@@ -158,10 +157,10 @@ Stream<DynamicNode> conversionsShouldBeAppliedToEntities() {
158157
() -> assertThat(ReflectionTestUtils.getField(thing, a.getKey()))
159158
.isEqualTo(a.getValue()))));
160159

161-
DynamicContainer writes = DynamicContainer.dynamicContainer("write", entry.getValue().entrySet().stream()
162-
.map(a -> DynamicTest
163-
.dynamicTest(a.getKey(),
164-
() -> assertWrite(copyOfThing, a.getKey(), defaultConversionService))));
160+
DynamicContainer writes = DynamicContainer.dynamicContainer("write", entry.getValue().keySet().stream()
161+
.map(o -> DynamicTest
162+
.dynamicTest(o,
163+
() -> assertWrite(copyOfThing, o, defaultConversionService))));
165164

166165
return DynamicContainer.dynamicContainer(entry.getKey(), Arrays.asList(reads, writes));
167166
});
@@ -172,8 +171,6 @@ void assertWrite(Object thing, String fieldName, ConversionService conversionSer
172171
long id = (long) ReflectionTestUtils.getField(thing, "id");
173172
Object domainValue = ReflectionTestUtils.getField(thing, fieldName);
174173

175-
Field field = ReflectionUtils.findField(thing.getClass(), fieldName);
176-
Optional<ConvertWith> annotation = AnnotationUtils.findAnnotation(field, ConvertWith.class);
177174
Function<Object, Value> conversion;
178175
if (fieldName.equals("dateAsLong")) {
179176
conversion = o -> Values.value(((Date) o).getTime());
@@ -185,8 +182,7 @@ void assertWrite(Object thing, String fieldName, ConversionService conversionSer
185182
Value driverValue;
186183
if (domainValue != null && Collection.class.isAssignableFrom(domainValue.getClass())) {
187184
Collection<?> sourceCollection = (Collection<?>) domainValue;
188-
Object[] targetCollection = (sourceCollection).stream()
189-
.map(element -> conversion.apply(element)).toArray();
185+
Object[] targetCollection = (sourceCollection).stream().map(conversion).toArray();
190186
driverValue = Values.value(targetCollection);
191187
} else {
192188
driverValue = conversion.apply(domainValue);
@@ -236,6 +232,34 @@ void parametersTargetingConvertedAttributesMustBeConverted(@Autowired CustomType
236232
.hasSizeGreaterThan(0);
237233
}
238234

235+
@Test // GH-2348
236+
void nonExistingPrimitivesShouldNotFailWithFieldAccess(@Autowired Neo4jTemplate template) {
237+
Long id;
238+
try (Session session = neo4jConnectionSupport.getDriver().session()) {
239+
240+
id = session.writeTransaction(tx -> tx.run("CREATE (n:ThingWithAllCypherTypes2) RETURN id(n)").single().get(0).asLong());
241+
}
242+
243+
Optional<ThingWithAllCypherTypes2> optionalResult = template.findById(id, ThingWithAllCypherTypes2.class);
244+
assertThat(optionalResult).hasValueSatisfying(result -> {
245+
assertThat(result.isABoolean()).isFalse();
246+
assertThat(result.getALong()).isEqualTo(0L);
247+
assertThat(result.getAnInt()).isEqualTo(0);
248+
assertThat(result.getADouble()).isEqualTo(0.0);
249+
assertThat(result.getAString()).isNull();
250+
assertThat(result.getAByteArray()).isNull();
251+
assertThat(result.getALocalDate()).isNull();
252+
assertThat(result.getAnOffsetTime()).isNull();
253+
assertThat(result.getALocalTime()).isNull();
254+
assertThat(result.getAZoneDateTime()).isNull();
255+
assertThat(result.getALocalDateTime()).isNull();
256+
assertThat(result.getAnIsoDuration()).isNull();
257+
assertThat(result.getAPoint()).isNull();
258+
assertThat(result.getAZeroPeriod()).isNull();
259+
assertThat(result.getAZeroDuration()).isNull();
260+
});
261+
}
262+
239263
public interface ConvertedIDsRepository extends Neo4jRepository<ThingWithUUIDID, UUID> {
240264
}
241265

@@ -248,18 +272,11 @@ public interface AdditionalTypesRepository extends Neo4jRepository<ThingWithAllA
248272
public interface SpatialTypesRepository extends Neo4jRepository<ThingWithAllSpatialTypes, Long> {
249273
}
250274

251-
public interface NonExistingPrimitivesRepository extends Neo4jRepository<ThingWithNonExistingPrimitives, Long> {
252-
}
253-
254275
public interface CustomTypesRepository extends Neo4jRepository<ThingWithCustomTypes, Long> {
255276

256277
List<ThingWithCustomTypes> findAllByDateAsString(Date theDate);
257278
}
258279

259-
public interface ThingWithCompositePropertiesRepository
260-
extends Neo4jRepository<ThingWithCompositeProperties, Long> {
261-
}
262-
263280
@Configuration
264281
@EnableNeo4jRepositories(considerNestedRepositories = true)
265282
@EnableTransactionManagement
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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.integration.shared.common;
17+
18+
import lombok.Getter;
19+
import lombok.RequiredArgsConstructor;
20+
import lombok.Setter;
21+
22+
import java.time.Duration;
23+
import java.time.LocalDate;
24+
import java.time.LocalDateTime;
25+
import java.time.LocalTime;
26+
import java.time.OffsetTime;
27+
import java.time.Period;
28+
import java.time.ZonedDateTime;
29+
30+
import org.neo4j.driver.types.IsoDuration;
31+
import org.neo4j.driver.types.Point;
32+
import org.springframework.data.neo4j.core.schema.GeneratedValue;
33+
import org.springframework.data.neo4j.core.schema.Id;
34+
import org.springframework.data.neo4j.core.schema.Node;
35+
36+
/**
37+
* Similar to {@link ThingWithAllCypherTypes} but using field access.
38+
* @author Michael J. Simons
39+
*/
40+
@Node
41+
@Getter
42+
@Setter
43+
@RequiredArgsConstructor
44+
public class ThingWithAllCypherTypes2 {
45+
46+
@Id @GeneratedValue private final Long id;
47+
48+
private boolean aBoolean;
49+
50+
private long aLong;
51+
52+
private int anInt;
53+
54+
private double aDouble;
55+
56+
private String aString;
57+
58+
private byte[] aByteArray;
59+
60+
private LocalDate aLocalDate;
61+
62+
private OffsetTime anOffsetTime;
63+
64+
private LocalTime aLocalTime;
65+
66+
private ZonedDateTime aZoneDateTime;
67+
68+
private LocalDateTime aLocalDateTime;
69+
70+
private IsoDuration anIsoDuration;
71+
72+
private Point aPoint;
73+
74+
private Period aZeroPeriod;
75+
76+
private Duration aZeroDuration;
77+
}

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

-39
This file was deleted.

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

-4
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ GeographicPoint3d asGeo3d(double height) {
187187
protected static long ID_OF_CYPHER_TYPES_NODE;
188188
protected static long ID_OF_ADDITIONAL_TYPES_NODE;
189189
protected static long ID_OF_SPATIAL_TYPES_NODE;
190-
protected static long ID_OF_NON_EXISTING_PRIMITIVES_NODE;
191190
protected static long ID_OF_CUSTOM_TYPE_NODE;
192191

193192
@BeforeAll
@@ -199,9 +198,6 @@ static void prepareData() {
199198

200199
w.run("MATCH (n) detach delete n");
201200

202-
ID_OF_NON_EXISTING_PRIMITIVES_NODE = w.run("CREATE (n:NonExistingPrimitives) RETURN id(n) AS id").single()
203-
.get("id").asLong();
204-
205201
parameters = new HashMap<>();
206202
parameters.put("aByteArray", "A thing".getBytes());
207203
ID_OF_CYPHER_TYPES_NODE = w.run("CREATE (n:CypherTypes) SET n.aBoolean = true,\n"

0 commit comments

Comments
 (0)