Skip to content

Commit f7b07ed

Browse files
GH-2572 - Filter out records that could not be mapped to allow Cypher OPTIONAL returns.
This fixes #2572 and is a tough call: The query presented there is totally valid but returns not an empty result but a record containing only one `null` element due to the way the Cyper `OPTIONAL` keyword works. We could try parsing the query, but this will be an endless rabbit whole in the future to come. The approach here is to check now whether a result contains exactly one record with one `NULL` value. If that is the case, no exception is thrown but `null` returned, which is later filtered on the clients. That however drops the checking for non-null values there, so that methods don’t throw in those cases but returns empty optionals or empty / smaller lists.
1 parent e9a2f1b commit f7b07ed

19 files changed

+369
-122
lines changed

src/main/java/org/springframework/data/neo4j/core/DefaultNeo4jClient.java

+4-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.util.Collections;
2020
import java.util.HashSet;
2121
import java.util.Map;
22+
import java.util.Objects;
2223
import java.util.Optional;
2324
import java.util.Set;
2425
import java.util.concurrent.locks.ReentrantReadWriteLock;
@@ -442,7 +443,7 @@ class DefaultRecordFetchSpec<T> implements RecordFetchSpec<T>, MappingSpec<T> {
442443
public RecordFetchSpec<T> mappedBy(
443444
@SuppressWarnings("HiddenField") BiFunction<TypeSystem, Record, T> mappingFunction) {
444445

445-
this.mappingFunction = new DelegatingMappingFunctionWithNullCheck<>(mappingFunction);
446+
this.mappingFunction = mappingFunction;
446447
return this;
447448
}
448449

@@ -468,7 +469,7 @@ public Optional<T> first() {
468469

469470
try (QueryRunner statementRunner = getQueryRunner(this.databaseSelection, this.impersonatedUser)) {
470471
Result result = runnableStatement.runWith(statementRunner);
471-
Optional<T> optionalValue = result.stream().map(partialMappingFunction(typeSystem)).findFirst();
472+
Optional<T> optionalValue = result.stream().map(partialMappingFunction(typeSystem)).filter(Objects::nonNull).findFirst();
472473
ResultSummaries.process(result.consume());
473474
return optionalValue;
474475
} catch (RuntimeException e) {
@@ -483,7 +484,7 @@ public Collection<T> all() {
483484

484485
try (QueryRunner statementRunner = getQueryRunner(this.databaseSelection, this.impersonatedUser)) {
485486
Result result = runnableStatement.runWith(statementRunner);
486-
Collection<T> values = result.stream().map(partialMappingFunction(typeSystem)).collect(Collectors.toList());
487+
Collection<T> values = result.stream().map(partialMappingFunction(typeSystem)).filter(Objects::nonNull).collect(Collectors.toList());
487488
ResultSummaries.process(result.consume());
488489
return values;
489490
} catch (RuntimeException e) {

src/main/java/org/springframework/data/neo4j/core/DefaultReactiveNeo4jClient.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ class DefaultRecordFetchSpec<T> implements RecordFetchSpec<T>, MappingSpec<T> {
398398
@Override
399399
public RecordFetchSpec<T> mappedBy(@SuppressWarnings("HiddenField") BiFunction<TypeSystem, Record, T> mappingFunction) {
400400

401-
this.mappingFunction = new DelegatingMappingFunctionWithNullCheck<>(mappingFunction);
401+
this.mappingFunction = mappingFunction;
402402
return this;
403403
}
404404

src/main/java/org/springframework/data/neo4j/core/DelegatingMappingFunctionWithNullCheck.java

-52
This file was deleted.

src/main/java/org/springframework/data/neo4j/core/Neo4jClient.java

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
public interface Neo4jClient {
4444

4545
LogAccessor cypherLog = new LogAccessor(LogFactory.getLog("org.springframework.data.neo4j.cypher"));
46+
LogAccessor log = new LogAccessor(LogFactory.getLog(Neo4jClient.class));
4647

4748
static Neo4jClient create(Driver driver) {
4849

src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jClient.java

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
public interface ReactiveNeo4jClient {
4747

4848
LogAccessor cypherLog = new LogAccessor(LogFactory.getLog("org.springframework.data.neo4j.cypher"));
49+
LogAccessor log = new LogAccessor(LogFactory.getLog(ReactiveNeo4jClient.class));
4950

5051
static ReactiveNeo4jClient create(Driver driver) {
5152

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

+19-12
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter {
104104
}
105105

106106
@Override
107+
@Nullable
107108
public <R> R read(Class<R> targetType, MapAccessor mapAccessor) {
108109

109110
knownObjects.nextRecord();
@@ -112,12 +113,9 @@ public <R> R read(Class<R> targetType, MapAccessor mapAccessor) {
112113
@SuppressWarnings("unchecked") // ¯\_(ツ)_/¯
113114
Neo4jPersistentEntity<R> rootNodeDescription = (Neo4jPersistentEntity<R>) nodeDescriptionStore.getNodeDescription(targetType);
114115
MapAccessor queryRoot = determineQueryRoot(mapAccessor, rootNodeDescription);
115-
if (queryRoot == null) {
116-
throw new NoRootNodeMappingException(String.format("Could not find mappable nodes or relationships inside %s for %s", mapAccessor, rootNodeDescription));
117-
}
118116

119117
try {
120-
return map(queryRoot, queryRoot, rootNodeDescription);
118+
return queryRoot == null ? null : map(queryRoot, queryRoot, rootNodeDescription);
121119
} catch (Exception e) {
122120
throw new MappingException("Error mapping " + mapAccessor, e);
123121
}
@@ -157,26 +155,35 @@ private <R> MapAccessor determineQueryRoot(MapAccessor mapAccessor, @Nullable Ne
157155

158156
// Prefer the candidates over candidates previously seen
159157
List<Node> finalCandidates = matchingNodes.isEmpty() ? seenMatchingNodes : matchingNodes;
160-
MapAccessor queryRoot = null;
161158

162159
if (finalCandidates.size() > 1) {
163160
throw new MappingException("More than one matching node in the record");
164161
} else if (!finalCandidates.isEmpty()) {
165162
if (mapAccessor.size() > 1) {
166-
queryRoot = mergeRootNodeWithRecord(finalCandidates.get(0), mapAccessor);
163+
return mergeRootNodeWithRecord(finalCandidates.get(0), mapAccessor);
167164
} else {
168-
queryRoot = finalCandidates.get(0);
165+
return finalCandidates.get(0);
169166
}
170167
} else {
168+
int cnt = 0;
169+
Value firstValue = Values.NULL;
171170
for (Value value : recordValues) {
172-
if (value.hasType(mapType) && !(value.hasType(nodeType) || value.hasType(
173-
relationshipType))) {
174-
queryRoot = value;
175-
break;
171+
if (cnt == 0) {
172+
firstValue = value;
176173
}
174+
if (value.hasType(mapType) && !(value.hasType(nodeType) || value.hasType(relationshipType))) {
175+
return value;
176+
}
177+
++cnt;
178+
}
179+
180+
// Cater for results that have one single, null column. This is the case for MATCH (x) OPTIONAL MATCH (something) RETURN something
181+
if (cnt == 1 && firstValue.isNull()) {
182+
return null;
177183
}
178184
}
179-
return queryRoot;
185+
186+
throw new NoRootNodeMappingException(mapAccessor, rootNodeDescription);
180187
}
181188

182189
private Collection<String> createDynamicLabelsProperty(TypeInformation<?> type, Collection<String> dynamicLabels) {

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

+21-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
*/
1616
package org.springframework.data.neo4j.core.mapping;
1717

18+
import java.util.Formattable;
19+
import java.util.Formatter;
20+
import java.util.Locale;
21+
1822
import org.apiguardian.api.API;
23+
import org.neo4j.driver.types.MapAccessor;
1924
import org.springframework.data.mapping.MappingException;
2025

2126
/**
@@ -29,9 +34,22 @@
2934
* @since 6.0.2
3035
*/
3136
@API(status = API.Status.INTERNAL, since = "6.0.2")
32-
public final class NoRootNodeMappingException extends MappingException {
37+
public final class NoRootNodeMappingException extends MappingException implements Formattable {
38+
39+
private MapAccessor mapAccessor;
40+
private Neo4jPersistentEntity<?> entity;
41+
42+
public NoRootNodeMappingException(MapAccessor mapAccessor, Neo4jPersistentEntity<?> entity) {
43+
super(String.format("Could not find mappable nodes or relationships inside %s for %s", mapAccessor, entity));
44+
this.mapAccessor = mapAccessor;
45+
this.entity = entity;
46+
}
3347

34-
public NoRootNodeMappingException(String s) {
35-
super(s);
48+
@Override
49+
public void formatTo(Formatter formatter, int flags, int width, int precision) {
50+
String className = entity.getUnderlyingClass().getSimpleName();
51+
formatter.format("Could not find mappable nodes or relationships inside %s for %s:%s", mapAccessor,
52+
className.substring(0, 1).toLowerCase(
53+
Locale.ROOT), String.join(":", entity.getStaticLabels()));
3654
}
3755
}

src/test/java/org/springframework/data/neo4j/core/DelegatingMappingFunctionWithNullCheckTest.java

-45
This file was deleted.

src/test/java/org/springframework/data/neo4j/core/Neo4jClientTest.java

+8-5
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
import static org.assertj.core.api.Assertions.assertThat;
1919
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
20-
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
2120
import static org.assertj.core.api.Assumptions.assumeThat;
2221
import static org.mockito.Mockito.any;
2322
import static org.mockito.Mockito.anyMap;
@@ -438,23 +437,27 @@ void shouldApplyNullChecksDuringReading() {
438437

439438
when(session.run(anyString(), anyMap())).thenReturn(result);
440439
when(result.stream()).thenReturn(Stream.of(record1, record2));
440+
when(result.consume()).thenReturn(resultSummary);
441441
when(record1.get("name")).thenReturn(Values.value("michael"));
442442

443443
Neo4jClient client = Neo4jClient.create(driver);
444444

445-
assertThatIllegalStateException()
446-
.isThrownBy(() -> client.query("MATCH (n) RETURN n").fetchAs(BikeOwner.class).mappedBy((t, r) -> {
445+
Collection<BikeOwner> owners = client.query("MATCH (n) RETURN n").fetchAs(BikeOwner.class)
446+
.mappedBy((t, r) -> {
447447
if (r == record1) {
448448
return new BikeOwner(r.get("name").asString(), Collections.emptyList());
449449
} else {
450450
return null;
451451
}
452-
}).all());
453-
452+
}).all();
453+
assertThat(owners).hasSize(1);
454454
verifyDatabaseSelection(null);
455455

456456
verify(session).run(eq("MATCH (n) RETURN n"), MockitoHamcrest.argThat(new MapAssertionMatcher(Collections.emptyMap())));
457457
verify(result).stream();
458+
verify(result).consume();
459+
verify(resultSummary).notifications();
460+
verify(resultSummary).hasPlan();
458461
verify(record1).get("name");
459462
verify(session).close();
460463
}

src/test/java/org/springframework/data/neo4j/core/ReactiveNeo4jClientTest.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ void shouldApplyNullChecksDuringReading() {
453453
}
454454
}).all();
455455

456-
StepVerifier.create(bikeOwners).expectNextCount(1).verifyError();
456+
StepVerifier.create(bikeOwners).expectNextCount(1).verifyComplete();
457457

458458
verifyDatabaseSelection(null);
459459

src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java

+45
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@
113113
import org.springframework.data.neo4j.integration.issues.gh2533.GH2533Repository;
114114
import org.springframework.data.neo4j.integration.issues.gh2542.TestNode;
115115
import org.springframework.data.neo4j.integration.issues.gh2542.TestNodeRepository;
116+
import org.springframework.data.neo4j.integration.issues.gh2572.GH2572Repository;
117+
import org.springframework.data.neo4j.integration.issues.gh2572.GH2572Child;
116118
import org.springframework.data.neo4j.integration.misc.ConcreteImplementationTwo;
117119
import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories;
118120
import org.springframework.data.neo4j.repository.query.QueryFragmentsAndParameters;
@@ -158,6 +160,7 @@ protected static void setupData(@Autowired BookmarkCapture bookmarkCapture) {
158160
setupGH2323(transaction);
159161
setupGH2328(transaction);
160162
setupGH2459(transaction);
163+
setupGH2572(transaction);
161164

162165
transaction.commit();
163166
}
@@ -789,6 +792,48 @@ void shouldThrowDataIntegrityViolationException(@Autowired TestNodeRepository re
789792
.isThrownBy(() -> repository.save(secondNode));
790793
}
791794

795+
@Test
796+
@Tag("GH-2572")
797+
void allShouldFetchCorrectNumberOfChildNodes(@Autowired GH2572Repository GH2572Repository) {
798+
List<GH2572Child> dogsForPerson = GH2572Repository.getDogsForPerson("GH2572Parent-2");
799+
assertThat(dogsForPerson).hasSize(2);
800+
}
801+
802+
@Test
803+
@Tag("GH-2572")
804+
void allShouldNotFailWithoutMatchingRootNodes(@Autowired GH2572Repository GH2572Repository) {
805+
List<GH2572Child> dogsForPerson = GH2572Repository.getDogsForPerson("GH2572Parent-1");
806+
assertThat(dogsForPerson).isEmpty();
807+
}
808+
809+
@Test
810+
@Tag("GH-2572")
811+
void oneShouldFetchCorrectNumberOfChildNodes(@Autowired GH2572Repository GH2572Repository) {
812+
Optional<GH2572Child> optionalChild = GH2572Repository.findOneDogForPerson("GH2572Parent-2");
813+
assertThat(optionalChild).map(GH2572Child::getName).hasValue("a-pet");
814+
}
815+
816+
@Test
817+
@Tag("GH-2572")
818+
void oneShouldNotFailWithoutMatchingRootNodes(@Autowired GH2572Repository GH2572Repository) {
819+
Optional<GH2572Child> optionalChild = GH2572Repository.findOneDogForPerson("GH2572Parent-1");
820+
assertThat(optionalChild).isEmpty();
821+
}
822+
823+
@Test
824+
@Tag("GH-2572")
825+
void getOneShouldFetchCorrectNumberOfChildNodes(@Autowired GH2572Repository GH2572Repository) {
826+
GH2572Child gh2572Child = GH2572Repository.getOneDogForPerson("GH2572Parent-2");
827+
assertThat(gh2572Child.getName()).isEqualTo("a-pet");
828+
}
829+
830+
@Test
831+
@Tag("GH-2572")
832+
void getOneShouldNotFailWithoutMatchingRootNodes(@Autowired GH2572Repository GH2572Repository) {
833+
GH2572Child gh2572Child = GH2572Repository.getOneDogForPerson("GH2572Parent-1");
834+
assertThat(gh2572Child).isNull();
835+
}
836+
792837
@Configuration
793838
@EnableTransactionManagement
794839
@EnableNeo4jRepositories(namedQueriesLocation = "more-custom-queries.properties")

0 commit comments

Comments
 (0)