From d3cdec8fd625f5bed7a020480f5e3c5f296baeff Mon Sep 17 00:00:00 2001 From: Dmitriy Tverdiakov Date: Mon, 21 Apr 2025 22:56:35 +0100 Subject: [PATCH 1/6] feat(object-mapping): Add support for mapping java.lang.Record to value Please note that this is a [feature preview](https://github.com/neo4j/neo4j-java-driver/blob/5.0/README.md#preview-features). This update adds support for mapping `java.lang.Record` to map value that may be useful when writing data to Neo4j. The following new method has been added to `org.neo4j.driver.Values`: ```java Value value(java.lang.Record record) ``` It returns a map value based on record components of a given `java.lang.Record`. Example (similar to the [Neo4j Movies Database](https://github.com/neo4j-graph-examples/movies)): ```java // assuming the following Java record public record Movie(String title, String tagline, long released) {} // a new movie may be created in the following way var movie = new Movie("title", "tagline", 2025); var movieValue = Values.value(movie); driver.executableQuery("CREATE (:Movie $movie)") .withParameters(Map.of("movie", movieValue)) .execute(); ``` Because the driver methods accepting a `Map` as query parameters automatically map values to value instances, it is possible to avoid mapping movie explicitly: ```java var movie = new Movie("title", "tagline", 2025); driver.executableQuery("CREATE (:Movie $movie)") .withParameters(Map.of("movie", movie)) .execute(); ``` Assuming movie titles being unique, it is possible to update the created movie in the following way: ```java var updatedMovie = new Movie("title", "updated tagline", 2024); driver.executableQuery(""" MATCH (movie:Movie {title: $movie.title}) SET movie += $movie """) .withParameters(Map.of("movie", updatedMovie)) .execute(); ``` The `Property` annotation may be used to override the record component name. ```java public record Movie(String title, String tagline, @Property("releasedYear") long released) {} ``` Note that those record components that have `null` value will be excluded from the map value. It is also important to understand that sending all properties over network may not always be desirable and will depend on a use-case. This new mapping as also available via the `Value value(Object value)` method, but with a note that it is still in preview status, not GA status. --- .../main/java/org/neo4j/driver/Values.java | 100 +++++++++++++++++- .../org/neo4j/driver/mapping/Property.java | 2 +- .../org/neo4j/driver/internal/ValuesTest.java | 83 +++++++++++++++ 3 files changed, 182 insertions(+), 3 deletions(-) diff --git a/driver/src/main/java/org/neo4j/driver/Values.java b/driver/src/main/java/org/neo4j/driver/Values.java index 0344aaf0d0..6d317d2681 100644 --- a/driver/src/main/java/org/neo4j/driver/Values.java +++ b/driver/src/main/java/org/neo4j/driver/Values.java @@ -60,6 +60,7 @@ import org.neo4j.driver.internal.value.PointValue; import org.neo4j.driver.internal.value.StringValue; import org.neo4j.driver.internal.value.TimeValue; +import org.neo4j.driver.mapping.Property; import org.neo4j.driver.types.Entity; import org.neo4j.driver.types.IsoDuration; import org.neo4j.driver.types.MapAccessor; @@ -68,6 +69,7 @@ import org.neo4j.driver.types.Point; import org.neo4j.driver.types.Relationship; import org.neo4j.driver.types.TypeSystem; +import org.neo4j.driver.util.Preview; /** * Utility for wrapping regular Java types and exposing them as {@link Value} @@ -95,6 +97,10 @@ private Values() { /** * Returns a value from object. + *

+ * Note that mapping from {@link java.lang.Record java.lang.Record} is in {@link Preview} status and should not + * be assumed to be in GA status. + * * @param value the object value * @return the array of values */ @@ -183,6 +189,9 @@ public static Value value(Object value) { if (value instanceof Stream) { return value((Stream) value); } + if (value instanceof java.lang.Record record) { + return value(record); + } if (value instanceof char[]) { return value((char[]) value); @@ -386,6 +395,93 @@ public static Value value(Stream stream) { return new ListValue(values); } + /** + * Returns a {@link TypeSystem#MAP() map} value based on {@link java.lang.reflect.RecordComponent record components} + * of a given {@link java.lang.Record Java Record}. + *

+ * Example (similar to the Neo4j Movies Database): + *

+     * {@code
+     * // assuming the following Java record
+     * public record Movie(String title, String tagline, long released) {}
+     * // a new movie may be created in the following way
+     * var movie = new Movie("title", "tagline", 2025);
+     * var movieValue = Values.value(movie);
+     * driver.executableQuery("CREATE (:Movie $movie)")
+     *         .withParameters(Map.of("movie", movieValue))
+     *         .execute();
+     * }
+     * 
+ * Because the driver methods accepting a {@code Map} as query parameters automatically map values + * to {@link Value} instances, it is possible to avoid mapping movie explicitly: + *
+     * {@code
+     * var movie = new Movie("title", "tagline", 2025);
+     * driver.executableQuery("CREATE (:Movie $movie)")
+     *         .withParameters(Map.of("movie", movie))
+     *         .execute();
+     * }
+     * 
+ * Assuming movie titles being unique, it is possible to update the created movie in the following way: + *
+     * {@code
+     * var updatedMovie = new Movie("title", "updated tagline", 2024);
+     * driver.executableQuery("""
+     *                 MATCH (movie:Movie {title: $movie.title})
+     *                 SET movie += $movie
+     *                 """)
+     *         .withParameters(Map.of("movie", updatedMovie))
+     *         .execute();
+     * }
+     * 
+ * The {@link Property} annotation may be used to override the record component name. + *
+     * {@code
+     * public record Movie(String title, String tagline, @Property("releasedYear") long released) {}
+     * }
+     * 
+ * Note that those record components that have {@code null} value will be excluded from the map value. + *

+ * It is also important to understand that sending all properties over network may not always be desirable and will + * depend on a use-case. + * + * @param record the record to map + * @return the map value + * @see TypeSystem#MAP() + * @see java.lang.Record + * @see java.lang.reflect.RecordComponent + * @see Property + * @since 5.28.5 + */ + @Preview(name = "Object mapping") + public static Value value(java.lang.Record record) { + var recordComponents = record.getClass().getRecordComponents(); + Map val = new HashMap<>(recordComponents.length); + for (var recordComponent : recordComponents) { + var propertyAnnotation = recordComponent.getAnnotation(Property.class); + var property = propertyAnnotation != null ? propertyAnnotation.value() : recordComponent.getName(); + Value value; + try { + var objectValue = recordComponent.getAccessor().invoke(record); + value = (objectValue != null) ? value(objectValue) : null; + } catch (Throwable throwable) { + var message = "Failed to map '%s' property to value during mapping '%s' to map value" + .formatted(property, record.getClass().getCanonicalName()); + throw new ClientException( + GqlStatusError.UNKNOWN.getStatus(), + GqlStatusError.UNKNOWN.getStatusDescription(message), + "N/A", + message, + GqlStatusError.DIAGNOSTIC_RECORD, + throwable); + } + if (value != null) { + val.put(property, value); + } + } + return new MapValue(val); + } + /** * Returns a value from char. * @param val the char value @@ -554,7 +650,7 @@ private static Value value(IsoDuration duration) { * @return the value */ public static Value point(int srid, double x, double y) { - return value(new InternalPoint2D(srid, x, y)); + return value((Point) new InternalPoint2D(srid, x, y)); } /** @@ -575,7 +671,7 @@ private static Value value(Point point) { * @return the value */ public static Value point(int srid, double x, double y, double z) { - return value(new InternalPoint3D(srid, x, y, z)); + return value((Point) new InternalPoint3D(srid, x, y, z)); } /** diff --git a/driver/src/main/java/org/neo4j/driver/mapping/Property.java b/driver/src/main/java/org/neo4j/driver/mapping/Property.java index 78be893c0c..07a20b91f6 100644 --- a/driver/src/main/java/org/neo4j/driver/mapping/Property.java +++ b/driver/src/main/java/org/neo4j/driver/mapping/Property.java @@ -27,7 +27,7 @@ * * @since 5.28.5 */ -@Target(ElementType.PARAMETER) +@Target({ElementType.PARAMETER, ElementType.RECORD_COMPONENT}) @Retention(RetentionPolicy.RUNTIME) @Preview(name = "Object mapping") public @interface Property { diff --git a/driver/src/test/java/org/neo4j/driver/internal/ValuesTest.java b/driver/src/test/java/org/neo4j/driver/internal/ValuesTest.java index e4737f63d0..48f24084f1 100644 --- a/driver/src/test/java/org/neo4j/driver/internal/ValuesTest.java +++ b/driver/src/test/java/org/neo4j/driver/internal/ValuesTest.java @@ -22,6 +22,7 @@ import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.collection.IsIterableContainingInOrder.contains; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.neo4j.driver.Values.isoDuration; @@ -50,10 +51,12 @@ import java.time.OffsetTime; import java.time.Period; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayDeque; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Stream; @@ -70,6 +73,8 @@ import org.neo4j.driver.internal.value.LocalTimeValue; import org.neo4j.driver.internal.value.MapValue; import org.neo4j.driver.internal.value.TimeValue; +import org.neo4j.driver.types.IsoDuration; +import org.neo4j.driver.types.Point; class ValuesTest { @Test @@ -522,4 +527,82 @@ void shouldCreateValueFromStreamOfNulls() { var value = value(stream); assertEquals(asList(null, null, null), value.asObject()); } + + @Test + void shouldMapJavaRecordToMap() { + // given + var string = "string"; + var listWithString = List.of(string, string); + var bytes = new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + var bool = false; + var boltInteger = Long.MIN_VALUE; + var boltFloat = Double.MIN_VALUE; + var date = LocalDate.now(); + var time = OffsetTime.now(); + var dateTime = ZonedDateTime.now(); + var localDateTime = LocalDateTime.now(); + var duration = new InternalIsoDuration(Duration.ZERO); + var period = Period.ofYears(1000); + var javaDuration = Duration.of(1000, ChronoUnit.MINUTES); + var point2d = new InternalPoint2D(0, 0, 0); + var point3d = new InternalPoint3D(0, 0, 0, 0); + var valueHolder = new ValueHolder( + string, + null, + listWithString, + bytes, + bool, + boltInteger, + boltFloat, + date, + time, + dateTime, + localDateTime, + duration, + period, + javaDuration, + point2d, + point3d); + + // when + var mapValue = Values.value(valueHolder); + + // then + assertEquals(15, mapValue.size()); + assertEquals(string, mapValue.get("string").as(String.class)); + assertFalse(mapValue.containsKey("nullValue")); + assertEquals(listWithString, mapValue.get("listWithString").as(List.class)); + assertEquals(bytes, mapValue.get("bytes").as(byte[].class)); + assertEquals(bool, mapValue.get("bool").as(boolean.class)); + assertEquals(boltInteger, mapValue.get("boltInteger").as(long.class)); + assertEquals(boltFloat, mapValue.get("boltFloat").as(double.class)); + assertEquals(date, mapValue.get("date").as(LocalDate.class)); + assertEquals(time, mapValue.get("time").as(OffsetTime.class)); + assertEquals(dateTime, mapValue.get("dateTime").as(ZonedDateTime.class)); + assertEquals(localDateTime, mapValue.get("localDateTime").as(LocalDateTime.class)); + assertEquals(duration, mapValue.get("duration").as(IsoDuration.class)); + assertEquals(period, mapValue.get("period").as(Period.class)); + assertEquals(javaDuration, mapValue.get("javaDuration").as(Duration.class)); + assertEquals(point2d, mapValue.get("point2d").as(Point.class)); + assertEquals(point3d, mapValue.get("point3d").as(Point.class)); + assertEquals(valueHolder, mapValue.as(ValueHolder.class)); + } + + public record ValueHolder( + String string, + Object nullValue, + List listWithString, + byte[] bytes, + boolean bool, + long boltInteger, + double boltFloat, + LocalDate date, + OffsetTime time, + ZonedDateTime dateTime, + LocalDateTime localDateTime, + IsoDuration duration, + Period period, + Duration javaDuration, + Point point2d, + Point point3d) {} } From 52c0a4c461c58b73a7cf84e0d5a35c7b008c0086 Mon Sep 17 00:00:00 2001 From: Dmitriy Tverdiakov Date: Wed, 23 Apr 2025 11:39:00 +0100 Subject: [PATCH 2/6] Fix ObjectMappingTests.shouldMapValue test --- driver/src/test/java/org/neo4j/driver/ObjectMappingTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/driver/src/test/java/org/neo4j/driver/ObjectMappingTests.java b/driver/src/test/java/org/neo4j/driver/ObjectMappingTests.java index 889db48ad0..3c8c025510 100644 --- a/driver/src/test/java/org/neo4j/driver/ObjectMappingTests.java +++ b/driver/src/test/java/org/neo4j/driver/ObjectMappingTests.java @@ -68,8 +68,8 @@ void shouldMapValue(Function, ValueHolder> valueFunction) { var duration = new InternalIsoDuration(Duration.ZERO); var period = Period.ofYears(1000); var javaDuration = Duration.of(1000, ChronoUnit.MINUTES); - var point2d = new InternalPoint2D(0, 0, 0); - var point3d = new InternalPoint3D(0, 0, 0, 0); + var point2d = (Point) new InternalPoint2D(0, 0, 0); + var point3d = (Point) new InternalPoint3D(0, 0, 0, 0); var properties = Map.ofEntries( Map.entry("string", Values.value(string)), From f8f4faffbeb88c02a3e424430c8e911433d999aa Mon Sep 17 00:00:00 2001 From: Dmitriy Tverdiakov Date: Wed, 23 Apr 2025 13:47:34 +0100 Subject: [PATCH 3/6] Add a bit more documentation --- driver/src/main/java/org/neo4j/driver/Values.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/driver/src/main/java/org/neo4j/driver/Values.java b/driver/src/main/java/org/neo4j/driver/Values.java index 6d317d2681..dc67583ad1 100644 --- a/driver/src/main/java/org/neo4j/driver/Values.java +++ b/driver/src/main/java/org/neo4j/driver/Values.java @@ -444,6 +444,11 @@ public static Value value(Stream stream) { *

* It is also important to understand that sending all properties over network may not always be desirable and will * depend on a use-case. + *

+ * In addition, please note that while this mapping may build nested structures, like map of maps, there are + * limitations on how those may be used by the database. Please read the Neo4j Cypher Manual for more up-to-date + * details. For example, at the time of writing, it is not possible to store maps as properties + * (see the following page). * * @param record the record to map * @return the map value From d3edeeb63210a8cbbd725a6babc5480ad6a67341 Mon Sep 17 00:00:00 2001 From: Dmitriy Tverdiakov Date: Wed, 23 Apr 2025 13:49:38 +0100 Subject: [PATCH 4/6] Update documentation --- driver/src/main/java/org/neo4j/driver/Values.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/driver/src/main/java/org/neo4j/driver/Values.java b/driver/src/main/java/org/neo4j/driver/Values.java index dc67583ad1..c496f94c3f 100644 --- a/driver/src/main/java/org/neo4j/driver/Values.java +++ b/driver/src/main/java/org/neo4j/driver/Values.java @@ -445,7 +445,7 @@ public static Value value(Stream stream) { * It is also important to understand that sending all properties over network may not always be desirable and will * depend on a use-case. *

- * In addition, please note that while this mapping may build nested structures, like map of maps, there are + * In addition, please note that while this mapping may build nested structures, like map of maps, there may be * limitations on how those may be used by the database. Please read the Neo4j Cypher Manual for more up-to-date * details. For example, at the time of writing, it is not possible to store maps as properties * (see the following page). From 5eba3960c0364ca6a84f5032748344379f5be5de Mon Sep 17 00:00:00 2001 From: Dmitriy Tverdiakov <11927660+injectives@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:03:36 +0100 Subject: [PATCH 5/6] Update driver/src/main/java/org/neo4j/driver/Values.java Co-authored-by: Gerrit Meier --- driver/src/main/java/org/neo4j/driver/Values.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/driver/src/main/java/org/neo4j/driver/Values.java b/driver/src/main/java/org/neo4j/driver/Values.java index c496f94c3f..8030301890 100644 --- a/driver/src/main/java/org/neo4j/driver/Values.java +++ b/driver/src/main/java/org/neo4j/driver/Values.java @@ -445,8 +445,8 @@ public static Value value(Stream stream) { * It is also important to understand that sending all properties over network may not always be desirable and will * depend on a use-case. *

- * In addition, please note that while this mapping may build nested structures, like map of maps, there may be - * limitations on how those may be used by the database. Please read the Neo4j Cypher Manual for more up-to-date + * In addition, please note that while this mapping allows nested structures, like map of maps, there may be + * limitations on how those are supported by the database. Please read the Neo4j Cypher Manual for more up-to-date * details. For example, at the time of writing, it is not possible to store maps as properties * (see the following page). * From 0e687cafd7b23b6eca603cca4fc4e2543ea230cb Mon Sep 17 00:00:00 2001 From: Dmitriy Tverdiakov Date: Wed, 23 Apr 2025 14:02:56 +0100 Subject: [PATCH 6/6] Update documentation --- driver/src/main/java/org/neo4j/driver/Values.java | 1 + 1 file changed, 1 insertion(+) diff --git a/driver/src/main/java/org/neo4j/driver/Values.java b/driver/src/main/java/org/neo4j/driver/Values.java index 8030301890..7bf5685f0d 100644 --- a/driver/src/main/java/org/neo4j/driver/Values.java +++ b/driver/src/main/java/org/neo4j/driver/Values.java @@ -456,6 +456,7 @@ public static Value value(Stream stream) { * @see java.lang.Record * @see java.lang.reflect.RecordComponent * @see Property + * @throws ClientException when mapping fails * @since 5.28.5 */ @Preview(name = "Object mapping")