Skip to content

Commit dabbecf

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 f6c1c94 commit dabbecf

16 files changed

+491
-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
@@ -100,6 +100,7 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter {
100100
}
101101

102102
@Override
103+
@Nullable
103104
public <R> R read(Class<R> targetType, MapAccessor mapAccessor) {
104105

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

115113
try {
116-
return map(queryRoot, queryRoot, rootNodeDescription);
114+
return queryRoot == null ? null : map(queryRoot, queryRoot, rootNodeDescription);
117115
} catch (Exception e) {
118116
throw new MappingException("Error mapping " + mapAccessor, e);
119117
}
@@ -153,26 +151,35 @@ private <R> MapAccessor determineQueryRoot(MapAccessor mapAccessor, @Nullable Ne
153151

154152
// Prefer the candidates over candidates previously seen
155153
List<Node> finalCandidates = matchingNodes.isEmpty() ? seenMatchingNodes : matchingNodes;
156-
MapAccessor queryRoot = null;
157154

158155
if (finalCandidates.size() > 1) {
159156
throw new MappingException("More than one matching node in the record.");
160157
} else if (!finalCandidates.isEmpty()) {
161158
if (mapAccessor.size() > 1) {
162-
queryRoot = mergeRootNodeWithRecord(finalCandidates.get(0), mapAccessor);
159+
return mergeRootNodeWithRecord(finalCandidates.get(0), mapAccessor);
163160
} else {
164-
queryRoot = finalCandidates.get(0);
161+
return finalCandidates.get(0);
165162
}
166163
} else {
164+
int cnt = 0;
165+
Value firstValue = Values.NULL;
167166
for (Value value : recordValues) {
168-
if (value.hasType(mapType) && !(value.hasType(nodeType) || value.hasType(
169-
relationshipType))) {
170-
queryRoot = value;
171-
break;
167+
if (cnt == 0) {
168+
firstValue = value;
172169
}
170+
if (value.hasType(mapType) && !(value.hasType(nodeType) || value.hasType(relationshipType))) {
171+
return value;
172+
}
173+
++cnt;
174+
}
175+
176+
// Cater for results that have one single, null column. This is the case for MATCH (x) OPTIONAL MATCH (something) RETURN something
177+
if (cnt == 1 && firstValue.isNull()) {
178+
return null;
173179
}
174180
}
175-
return queryRoot;
181+
182+
throw new NoRootNodeMappingException(mapAccessor, rootNodeDescription);
176183
}
177184

178185
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
@@ -449,7 +449,7 @@ void shouldApplyNullChecksDuringReading() {
449449
}
450450
}).all();
451451

452-
StepVerifier.create(bikeOwners).expectNextCount(1).verifyError();
452+
StepVerifier.create(bikeOwners).expectNextCount(1).verifyComplete();
453453

454454
verifyDatabaseSelection(null);
455455

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright 2011-2022 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.issues.gh2572;
17+
18+
import org.springframework.data.neo4j.core.schema.GeneratedValue;
19+
import org.springframework.data.neo4j.core.schema.Id;
20+
21+
/**
22+
* @author Michael J. Simons
23+
* @param <T> The concrete type
24+
*/
25+
abstract class GH2572BaseEntity<T extends GH2572BaseEntity<T>> {
26+
27+
@Id
28+
@GeneratedValue(value = MyStrategy.class)
29+
protected String id;
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2011-2022 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.issues.gh2572;
17+
18+
import lombok.AllArgsConstructor;
19+
import lombok.Getter;
20+
import lombok.NoArgsConstructor;
21+
import lombok.Setter;
22+
23+
import org.springframework.data.neo4j.core.schema.Node;
24+
import org.springframework.data.neo4j.core.schema.Relationship;
25+
26+
/**
27+
* @author Michael J. Simons
28+
*/
29+
@Getter
30+
@Setter
31+
@NoArgsConstructor
32+
@AllArgsConstructor
33+
@Node
34+
public class GH2572Child extends GH2572BaseEntity<GH2572Child> {
35+
private String name;
36+
37+
@Relationship(value = "IS_PET", direction = Relationship.Direction.OUTGOING)
38+
private GH2572Parent owner;
39+
}

0 commit comments

Comments
 (0)