Skip to content

Commit feb02c6

Browse files
committed
feat(object-mapping): support mapping types with restricted access
This update introduces an additional `TryAccessible` annotation that enables the Object Mapper to use `AccessibleObject#trySetAccessible()` when seaching for constructor to use. However, it prefers constructors that are accessible by default when they have the same number of matched and mismatched properties. Given the objective of providing an easy and type-safe way of accessing user-defined values in `MapAccessor`, it would would sometimes to convenient to define the target type and map to it within the same method. For example: ```java @TryAccessible record Movie(String title, String tagline, long released) {} var movies = driver.executableQuery("MATCH (movie:Movie) RETURN movie") .execute() .records() .stream() .map(record -> record.get("movie").as(Movie.class)) .toList(); ``` However, such Java `record` has restricted access from Object Mapping implementation perspective. As long as `AccessibleObject#trySetAccessible()` is successful, this update makes it possible to map to such types. In addition, the following bugs have been fixed: - `null` type name was used in exception message when local `record` mapping failed - nonexistent properties were considered as matched on nodes and relationships
1 parent 891ca3f commit feb02c6

File tree

6 files changed

+202
-21
lines changed

6 files changed

+202
-21
lines changed

driver/src/main/java/org/neo4j/driver/Value.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.neo4j.driver.exceptions.value.Uncoercible;
3232
import org.neo4j.driver.internal.value.NullValue;
3333
import org.neo4j.driver.mapping.Property;
34+
import org.neo4j.driver.mapping.TryAccessible;
3435
import org.neo4j.driver.types.Entity;
3536
import org.neo4j.driver.types.IsoDuration;
3637
import org.neo4j.driver.types.MapAccessor;
@@ -724,8 +725,13 @@ public interface Value extends MapAccessor, MapAccessorWithDefaultValue {
724725
* <li>Maximum matching properties.</li>
725726
* <li>Minimum mismatching properties.</li>
726727
* </ol>
727-
* The constructor search is done in the order defined by the {@link Class#getDeclaredConstructors} and is
728-
* finished either when a full match is found with no mismatches or once all constructors have been visited.
728+
* The constructor search is done in the order defined by the {@link Class#getDeclaredConstructors}.
729+
* <p>
730+
* Only constructors that are accessible or can be made accessible (see {@link TryAccessible}) are included in the
731+
* search.
732+
* <p>
733+
* The search finishes as soon as a constructor that is accessible by default and matches all properties is found.
734+
* Otherwise, it finishes once all constructors have been visited.
729735
* <p>
730736
* At least 1 property match must be present for mapping to work.
731737
* <p>

driver/src/main/java/org/neo4j/driver/internal/value/mapping/ConstructorFinder.java

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,31 +20,46 @@
2020
import java.util.ArrayList;
2121
import java.util.List;
2222
import java.util.Optional;
23-
import org.neo4j.driver.Values;
23+
import java.util.Set;
24+
import org.neo4j.driver.Value;
2425
import org.neo4j.driver.internal.value.InternalValue;
2526
import org.neo4j.driver.mapping.Property;
27+
import org.neo4j.driver.mapping.TryAccessible;
2628
import org.neo4j.driver.types.MapAccessor;
29+
import org.neo4j.driver.types.Type;
30+
import org.neo4j.driver.types.TypeSystem;
2731

2832
class ConstructorFinder {
33+
private static final TypeSystem TS = TypeSystem.getDefault();
34+
35+
private static final Set<Type> ENTITY_TYPES = Set.of(TS.NODE(), TS.RELATIONSHIP());
36+
2937
@SuppressWarnings("unchecked")
3038
public <T> Optional<ObjectMetadata<T>> findConstructor(MapAccessor mapAccessor, Class<T> targetClass) {
3139
PropertiesMatch<T> bestPropertiesMatch = null;
3240
var constructors = targetClass.getDeclaredConstructors();
41+
var tryAccess = targetClass.isAnnotationPresent(TryAccessible.class);
3342
var propertyNamesSize = mapAccessor.size();
3443
for (var constructor : constructors) {
35-
var accessible = false;
36-
try {
37-
accessible = constructor.canAccess(null);
38-
} catch (Throwable e) {
39-
// ignored
40-
}
41-
if (!accessible) {
42-
continue;
43-
}
4444
var matchNumbers = matchPropertyNames(mapAccessor, constructor);
4545
if (bestPropertiesMatch == null
4646
|| (matchNumbers.match() >= bestPropertiesMatch.match()
4747
&& matchNumbers.mismatch() < bestPropertiesMatch.mismatch())) {
48+
// no match yet or better match
49+
if (matchNumbers.isAccessible()) {
50+
bestPropertiesMatch = (PropertiesMatch<T>) matchNumbers;
51+
if (bestPropertiesMatch.match() == propertyNamesSize && bestPropertiesMatch.mismatch() == 0) {
52+
break;
53+
}
54+
} else if (tryAccess && constructor.trySetAccessible()) {
55+
bestPropertiesMatch = (PropertiesMatch<T>) matchNumbers;
56+
// no break as an accessible may be available
57+
}
58+
} else if (matchNumbers.match() == bestPropertiesMatch.match()
59+
&& matchNumbers.mismatch() == bestPropertiesMatch.mismatch()
60+
&& matchNumbers.isAccessible()
61+
&& !bestPropertiesMatch.isAccessible()) {
62+
// identical match, but the new one is accessible
4863
bestPropertiesMatch = (PropertiesMatch<T>) matchNumbers;
4964
if (bestPropertiesMatch.match() == propertyNamesSize && bestPropertiesMatch.mismatch() == 0) {
5065
break;
@@ -66,19 +81,37 @@ private <T> PropertiesMatch<T> matchPropertyNames(MapAccessor mapAccessor, Const
6681
for (var parameter : parameters) {
6782
var propertyNameAnnotation = parameter.getAnnotation(Property.class);
6883
var propertyName = propertyNameAnnotation != null ? propertyNameAnnotation.value() : parameter.getName();
69-
var value = mapAccessor.get(propertyName);
70-
if (value != null) {
84+
if (contains(mapAccessor, propertyName)) {
7185
match++;
7286
} else {
7387
mismatch++;
7488
}
7589
arguments.add(new Argument(
76-
propertyName,
77-
parameter.getParameterizedType(),
78-
value != null ? (InternalValue) value : (InternalValue) Values.NULL));
90+
propertyName, parameter.getParameterizedType(), (InternalValue) mapAccessor.get(propertyName)));
91+
}
92+
return new PropertiesMatch<>(match, mismatch, constructor, arguments, isAccessible(constructor));
93+
}
94+
95+
private boolean contains(MapAccessor mapAccessor, String propertyName) {
96+
if (mapAccessor instanceof Value value) {
97+
if (ENTITY_TYPES.contains(value.type())) {
98+
return value.asEntity().containsKey(propertyName);
99+
} else {
100+
return mapAccessor.containsKey(propertyName);
101+
}
102+
} else {
103+
return mapAccessor.containsKey(propertyName);
104+
}
105+
}
106+
107+
private boolean isAccessible(Constructor<?> constructor) {
108+
try {
109+
return constructor.canAccess(null);
110+
} catch (Exception e) {
111+
return false;
79112
}
80-
return new PropertiesMatch<>(match, mismatch, constructor, arguments);
81113
}
82114

83-
private record PropertiesMatch<T>(int match, int mismatch, Constructor<T> constructor, List<Argument> arguments) {}
115+
private record PropertiesMatch<T>(
116+
int match, int mismatch, Constructor<T> constructor, List<Argument> arguments, boolean isAccessible) {}
84117
}

driver/src/main/java/org/neo4j/driver/internal/value/mapping/ObjectInstantiator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ObjectInstantiator {
2323

2424
<T> T instantiate(ObjectMetadata<T> metadata) {
2525
var constructor = metadata.constructor();
26-
var targetTypeName = constructor.getDeclaringClass().getCanonicalName();
26+
var targetTypeName = constructor.getDeclaringClass().getName();
2727
var initargs = initargs(targetTypeName, metadata.arguments());
2828
try {
2929
return constructor.newInstance(initargs);

driver/src/main/java/org/neo4j/driver/internal/value/mapping/ObjectMapper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,6 @@ public T map(MapAccessor mapAccessor, Class<T> targetClass) {
4545
.findConstructor(mapAccessor, targetClass)
4646
.map(OBJECT_INSTANTIATOR::instantiate)
4747
.orElseThrow(() -> new ValueException(
48-
"No suitable constructor has been found for '%s'".formatted(targetClass.getCanonicalName())));
48+
"No suitable constructor has been found for '%s'".formatted(targetClass.getName())));
4949
}
5050
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (c) "Neo4j"
3+
* Neo4j Sweden AB [https://neo4j.com]
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.neo4j.driver.mapping;
18+
19+
import java.lang.annotation.ElementType;
20+
import java.lang.annotation.Retention;
21+
import java.lang.annotation.RetentionPolicy;
22+
import java.lang.annotation.Target;
23+
import java.lang.reflect.AccessibleObject;
24+
import org.neo4j.driver.util.Preview;
25+
26+
/**
27+
* Enables the Object Mapper to use {@link AccessibleObject#trySetAccessible()} when seaching for constructor to use.
28+
* <p>
29+
* When multiple constructors have the same number of matched and mismatched properties, the first consctructor that is
30+
* accessible by default (does not require {@link AccessibleObject#trySetAccessible()}) is selected.
31+
* <p>
32+
* Example (using the <a href=https://github.com/neo4j-graph-examples/movies>Neo4j Movies Database</a>):
33+
* <pre>
34+
* {@code
35+
* // assuming the following local Java record
36+
* @TryAccessible
37+
* record Movie(String title, String tagline, long released) {}
38+
* // the nodes may be mapped to Movie instances
39+
* var movies = driver.executableQuery("MATCH (movie:Movie) RETURN movie")
40+
* .execute()
41+
* .records()
42+
* .stream()
43+
* .map(record -> record.get("movie").as(Movie.class))
44+
* .toList();
45+
* }
46+
* </pre>
47+
*
48+
* @since 5.28.8
49+
*/
50+
@Target(ElementType.TYPE)
51+
@Retention(RetentionPolicy.RUNTIME)
52+
@Preview(name = "Object mapping")
53+
public @interface TryAccessible {}

driver/src/test/java/org/neo4j/driver/ObjectMappingTests.java

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import static org.junit.jupiter.api.Assertions.assertEquals;
2020
import static org.junit.jupiter.api.Assertions.assertNull;
21+
import static org.junit.jupiter.api.Assertions.assertThrows;
2122

2223
import java.time.Duration;
2324
import java.time.LocalDate;
@@ -37,6 +38,7 @@
3738
import org.junit.jupiter.params.ParameterizedTest;
3839
import org.junit.jupiter.params.provider.Arguments;
3940
import org.junit.jupiter.params.provider.MethodSource;
41+
import org.neo4j.driver.exceptions.value.ValueException;
4042
import org.neo4j.driver.internal.InternalIsoDuration;
4143
import org.neo4j.driver.internal.InternalNode;
4244
import org.neo4j.driver.internal.InternalPoint2D;
@@ -46,6 +48,7 @@
4648
import org.neo4j.driver.internal.value.NodeValue;
4749
import org.neo4j.driver.internal.value.RelationshipValue;
4850
import org.neo4j.driver.mapping.Property;
51+
import org.neo4j.driver.mapping.TryAccessible;
4952
import org.neo4j.driver.types.IsoDuration;
5053
import org.neo4j.driver.types.Point;
5154

@@ -189,4 +192,90 @@ public ValueHolderWithOptionalNumber(
189192
this(string, bytes, bool, Long.MIN_VALUE);
190193
}
191194
}
195+
196+
@Test
197+
void shouldWorkWithLocalRecord() {
198+
// given
199+
var string = "string";
200+
var bool = false;
201+
202+
var properties =
203+
Map.ofEntries(Map.entry("string", Values.value(string)), Map.entry("bool", Values.value(bool)));
204+
205+
@TryAccessible
206+
record LocalRecord(String string, boolean bool) {}
207+
208+
// when
209+
var valueHolder = Values.value(properties).as(LocalRecord.class);
210+
211+
// then
212+
assertEquals(string, valueHolder.string());
213+
assertEquals(bool, valueHolder.bool());
214+
}
215+
216+
@Test
217+
void shouldWorkWithPrivateRecord() {
218+
// given
219+
var string = "string";
220+
var bool = false;
221+
222+
var properties =
223+
Map.ofEntries(Map.entry("string", Values.value(string)), Map.entry("bool", Values.value(bool)));
224+
225+
// when
226+
var valueHolder = Values.value(properties).as(PrivateRecord.class);
227+
228+
// then
229+
assertEquals(string, valueHolder.string());
230+
assertEquals(bool, valueHolder.bool());
231+
}
232+
233+
@TryAccessible
234+
private record PrivateRecord(String string, boolean bool) {}
235+
236+
@Test
237+
void shouldSelectAccessibleOnIdenticalMatch() {
238+
// given
239+
var string = "string";
240+
var bool = false;
241+
var number = 0;
242+
243+
var properties = Map.ofEntries(
244+
Map.entry("string", Values.value(string)),
245+
Map.entry("bool", Values.value(bool)),
246+
Map.entry("number", Values.value(number)));
247+
248+
// when
249+
var valueHolder = Values.value(properties).as(IdenticalMatch.class);
250+
251+
// then
252+
assertEquals(string, valueHolder.string);
253+
assertEquals(number, valueHolder.number);
254+
}
255+
256+
public static class IdenticalMatch {
257+
String string;
258+
boolean bool;
259+
long number;
260+
261+
private IdenticalMatch(@Property("string") String string, @Property("bool") boolean bool) {
262+
this.string = string;
263+
this.bool = bool;
264+
}
265+
266+
public IdenticalMatch(@Property("string") String string, @Property("number") int number) {
267+
this.string = string;
268+
this.number = number;
269+
}
270+
}
271+
272+
@Test
273+
void shouldFindNoMatch() {
274+
// given
275+
var properties = Map.ofEntries(Map.entry("value", Values.value("value")));
276+
277+
// when & then
278+
var exception = assertThrows(
279+
ValueException.class, () -> Values.value(properties).as(ValueHolder.class));
280+
}
192281
}

0 commit comments

Comments
 (0)