Skip to content

Commit d3cdec8

Browse files
committed
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<String, Object>` 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.
1 parent 17304b7 commit d3cdec8

File tree

3 files changed

+182
-3
lines changed

3 files changed

+182
-3
lines changed

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

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
import org.neo4j.driver.internal.value.PointValue;
6161
import org.neo4j.driver.internal.value.StringValue;
6262
import org.neo4j.driver.internal.value.TimeValue;
63+
import org.neo4j.driver.mapping.Property;
6364
import org.neo4j.driver.types.Entity;
6465
import org.neo4j.driver.types.IsoDuration;
6566
import org.neo4j.driver.types.MapAccessor;
@@ -68,6 +69,7 @@
6869
import org.neo4j.driver.types.Point;
6970
import org.neo4j.driver.types.Relationship;
7071
import org.neo4j.driver.types.TypeSystem;
72+
import org.neo4j.driver.util.Preview;
7173

7274
/**
7375
* Utility for wrapping regular Java types and exposing them as {@link Value}
@@ -95,6 +97,10 @@ private Values() {
9597

9698
/**
9799
* Returns a value from object.
100+
* <p>
101+
* <b>Note that mapping from {@link java.lang.Record java.lang.Record} is in {@link Preview} status and should not
102+
* be assumed to be in GA status.</b>
103+
*
98104
* @param value the object value
99105
* @return the array of values
100106
*/
@@ -183,6 +189,9 @@ public static Value value(Object value) {
183189
if (value instanceof Stream<?>) {
184190
return value((Stream<Object>) value);
185191
}
192+
if (value instanceof java.lang.Record record) {
193+
return value(record);
194+
}
186195

187196
if (value instanceof char[]) {
188197
return value((char[]) value);
@@ -386,6 +395,93 @@ public static Value value(Stream<Object> stream) {
386395
return new ListValue(values);
387396
}
388397

398+
/**
399+
* Returns a {@link TypeSystem#MAP() map} value based on {@link java.lang.reflect.RecordComponent record components}
400+
* of a given {@link java.lang.Record Java Record}.
401+
* <p>
402+
* Example (similar to the <a href=https://github.com/neo4j-graph-examples/movies>Neo4j Movies Database</a>):
403+
* <pre>
404+
* {@code
405+
* // assuming the following Java record
406+
* public record Movie(String title, String tagline, long released) {}
407+
* // a new movie may be created in the following way
408+
* var movie = new Movie("title", "tagline", 2025);
409+
* var movieValue = Values.value(movie);
410+
* driver.executableQuery("CREATE (:Movie $movie)")
411+
* .withParameters(Map.of("movie", movieValue))
412+
* .execute();
413+
* }
414+
* </pre>
415+
* Because the driver methods accepting a {@code Map<String, Object>} as query parameters automatically map values
416+
* to {@link Value} instances, it is possible to avoid mapping movie explicitly:
417+
* <pre>
418+
* {@code
419+
* var movie = new Movie("title", "tagline", 2025);
420+
* driver.executableQuery("CREATE (:Movie $movie)")
421+
* .withParameters(Map.of("movie", movie))
422+
* .execute();
423+
* }
424+
* </pre>
425+
* Assuming movie titles being unique, it is possible to update the created movie in the following way:
426+
* <pre>
427+
* {@code
428+
* var updatedMovie = new Movie("title", "updated tagline", 2024);
429+
* driver.executableQuery("""
430+
* MATCH (movie:Movie {title: $movie.title})
431+
* SET movie += $movie
432+
* """)
433+
* .withParameters(Map.of("movie", updatedMovie))
434+
* .execute();
435+
* }
436+
* </pre>
437+
* The {@link Property} annotation may be used to override the record component name.
438+
* <pre>
439+
* {@code
440+
* public record Movie(String title, String tagline, @Property("releasedYear") long released) {}
441+
* }
442+
* </pre>
443+
* Note that those record components that have {@code null} value will be excluded from the map value.
444+
* <p>
445+
* It is also important to understand that sending all properties over network may not always be desirable and will
446+
* depend on a use-case.
447+
*
448+
* @param record the record to map
449+
* @return the map value
450+
* @see TypeSystem#MAP()
451+
* @see java.lang.Record
452+
* @see java.lang.reflect.RecordComponent
453+
* @see Property
454+
* @since 5.28.5
455+
*/
456+
@Preview(name = "Object mapping")
457+
public static Value value(java.lang.Record record) {
458+
var recordComponents = record.getClass().getRecordComponents();
459+
Map<String, Value> val = new HashMap<>(recordComponents.length);
460+
for (var recordComponent : recordComponents) {
461+
var propertyAnnotation = recordComponent.getAnnotation(Property.class);
462+
var property = propertyAnnotation != null ? propertyAnnotation.value() : recordComponent.getName();
463+
Value value;
464+
try {
465+
var objectValue = recordComponent.getAccessor().invoke(record);
466+
value = (objectValue != null) ? value(objectValue) : null;
467+
} catch (Throwable throwable) {
468+
var message = "Failed to map '%s' property to value during mapping '%s' to map value"
469+
.formatted(property, record.getClass().getCanonicalName());
470+
throw new ClientException(
471+
GqlStatusError.UNKNOWN.getStatus(),
472+
GqlStatusError.UNKNOWN.getStatusDescription(message),
473+
"N/A",
474+
message,
475+
GqlStatusError.DIAGNOSTIC_RECORD,
476+
throwable);
477+
}
478+
if (value != null) {
479+
val.put(property, value);
480+
}
481+
}
482+
return new MapValue(val);
483+
}
484+
389485
/**
390486
* Returns a value from char.
391487
* @param val the char value
@@ -554,7 +650,7 @@ private static Value value(IsoDuration duration) {
554650
* @return the value
555651
*/
556652
public static Value point(int srid, double x, double y) {
557-
return value(new InternalPoint2D(srid, x, y));
653+
return value((Point) new InternalPoint2D(srid, x, y));
558654
}
559655

560656
/**
@@ -575,7 +671,7 @@ private static Value value(Point point) {
575671
* @return the value
576672
*/
577673
public static Value point(int srid, double x, double y, double z) {
578-
return value(new InternalPoint3D(srid, x, y, z));
674+
return value((Point) new InternalPoint3D(srid, x, y, z));
579675
}
580676

581677
/**

driver/src/main/java/org/neo4j/driver/mapping/Property.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
*
2828
* @since 5.28.5
2929
*/
30-
@Target(ElementType.PARAMETER)
30+
@Target({ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
3131
@Retention(RetentionPolicy.RUNTIME)
3232
@Preview(name = "Object mapping")
3333
public @interface Property {

driver/src/test/java/org/neo4j/driver/internal/ValuesTest.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import static org.hamcrest.Matchers.instanceOf;
2323
import static org.hamcrest.collection.IsIterableContainingInOrder.contains;
2424
import static org.junit.jupiter.api.Assertions.assertEquals;
25+
import static org.junit.jupiter.api.Assertions.assertFalse;
2526
import static org.junit.jupiter.api.Assertions.assertNotEquals;
2627
import static org.junit.jupiter.api.Assertions.assertThrows;
2728
import static org.neo4j.driver.Values.isoDuration;
@@ -50,10 +51,12 @@
5051
import java.time.OffsetTime;
5152
import java.time.Period;
5253
import java.time.ZonedDateTime;
54+
import java.time.temporal.ChronoUnit;
5355
import java.util.ArrayDeque;
5456
import java.util.Collection;
5557
import java.util.HashMap;
5658
import java.util.HashSet;
59+
import java.util.List;
5760
import java.util.Map;
5861
import java.util.Set;
5962
import java.util.stream.Stream;
@@ -70,6 +73,8 @@
7073
import org.neo4j.driver.internal.value.LocalTimeValue;
7174
import org.neo4j.driver.internal.value.MapValue;
7275
import org.neo4j.driver.internal.value.TimeValue;
76+
import org.neo4j.driver.types.IsoDuration;
77+
import org.neo4j.driver.types.Point;
7378

7479
class ValuesTest {
7580
@Test
@@ -522,4 +527,82 @@ void shouldCreateValueFromStreamOfNulls() {
522527
var value = value(stream);
523528
assertEquals(asList(null, null, null), value.asObject());
524529
}
530+
531+
@Test
532+
void shouldMapJavaRecordToMap() {
533+
// given
534+
var string = "string";
535+
var listWithString = List.of(string, string);
536+
var bytes = new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
537+
var bool = false;
538+
var boltInteger = Long.MIN_VALUE;
539+
var boltFloat = Double.MIN_VALUE;
540+
var date = LocalDate.now();
541+
var time = OffsetTime.now();
542+
var dateTime = ZonedDateTime.now();
543+
var localDateTime = LocalDateTime.now();
544+
var duration = new InternalIsoDuration(Duration.ZERO);
545+
var period = Period.ofYears(1000);
546+
var javaDuration = Duration.of(1000, ChronoUnit.MINUTES);
547+
var point2d = new InternalPoint2D(0, 0, 0);
548+
var point3d = new InternalPoint3D(0, 0, 0, 0);
549+
var valueHolder = new ValueHolder(
550+
string,
551+
null,
552+
listWithString,
553+
bytes,
554+
bool,
555+
boltInteger,
556+
boltFloat,
557+
date,
558+
time,
559+
dateTime,
560+
localDateTime,
561+
duration,
562+
period,
563+
javaDuration,
564+
point2d,
565+
point3d);
566+
567+
// when
568+
var mapValue = Values.value(valueHolder);
569+
570+
// then
571+
assertEquals(15, mapValue.size());
572+
assertEquals(string, mapValue.get("string").as(String.class));
573+
assertFalse(mapValue.containsKey("nullValue"));
574+
assertEquals(listWithString, mapValue.get("listWithString").as(List.class));
575+
assertEquals(bytes, mapValue.get("bytes").as(byte[].class));
576+
assertEquals(bool, mapValue.get("bool").as(boolean.class));
577+
assertEquals(boltInteger, mapValue.get("boltInteger").as(long.class));
578+
assertEquals(boltFloat, mapValue.get("boltFloat").as(double.class));
579+
assertEquals(date, mapValue.get("date").as(LocalDate.class));
580+
assertEquals(time, mapValue.get("time").as(OffsetTime.class));
581+
assertEquals(dateTime, mapValue.get("dateTime").as(ZonedDateTime.class));
582+
assertEquals(localDateTime, mapValue.get("localDateTime").as(LocalDateTime.class));
583+
assertEquals(duration, mapValue.get("duration").as(IsoDuration.class));
584+
assertEquals(period, mapValue.get("period").as(Period.class));
585+
assertEquals(javaDuration, mapValue.get("javaDuration").as(Duration.class));
586+
assertEquals(point2d, mapValue.get("point2d").as(Point.class));
587+
assertEquals(point3d, mapValue.get("point3d").as(Point.class));
588+
assertEquals(valueHolder, mapValue.as(ValueHolder.class));
589+
}
590+
591+
public record ValueHolder(
592+
String string,
593+
Object nullValue,
594+
List<String> listWithString,
595+
byte[] bytes,
596+
boolean bool,
597+
long boltInteger,
598+
double boltFloat,
599+
LocalDate date,
600+
OffsetTime time,
601+
ZonedDateTime dateTime,
602+
LocalDateTime localDateTime,
603+
IsoDuration duration,
604+
Period period,
605+
Duration javaDuration,
606+
Point point2d,
607+
Point point3d) {}
525608
}

0 commit comments

Comments
 (0)