diff --git a/driver/src/main/java/org/neo4j/driver/Values.java b/driver/src/main/java/org/neo4j/driver/Values.java
index 0344aaf0d0..7bf5685f0d 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,99 @@ 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.
+ *
+ * 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 ).
+ *
+ * @param record the record to map
+ * @return the map value
+ * @see TypeSystem#MAP()
+ * @see java.lang.Record
+ * @see java.lang.reflect.RecordComponent
+ * @see Property
+ * @throws ClientException when mapping fails
+ * @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 +656,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 +677,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/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)),
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) {}
}