Skip to content

feat(object-mapping): Add support for mapping java.lang.Record to value #1638

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 98 additions & 2 deletions driver/src/main/java/org/neo4j/driver/Values.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}
Expand Down Expand Up @@ -95,6 +97,10 @@ private Values() {

/**
* Returns a value from object.
* <p>
* <b>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.</b>
*
* @param value the object value
* @return the array of values
*/
Expand Down Expand Up @@ -183,6 +189,9 @@ public static Value value(Object value) {
if (value instanceof Stream<?>) {
return value((Stream<Object>) value);
}
if (value instanceof java.lang.Record record) {
return value(record);
}

if (value instanceof char[]) {
return value((char[]) value);
Expand Down Expand Up @@ -386,6 +395,93 @@ public static Value value(Stream<Object> 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}.
* <p>
* Example (similar to the <a href=https://github.com/neo4j-graph-examples/movies>Neo4j Movies Database</a>):
* <pre>
* {@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();
* }
* </pre>
* Because the driver methods accepting a {@code Map<String, Object>} as query parameters automatically map values
* to {@link Value} instances, it is possible to avoid mapping movie explicitly:
* <pre>
* {@code
* var movie = new Movie("title", "tagline", 2025);
* driver.executableQuery("CREATE (:Movie $movie)")
* .withParameters(Map.of("movie", movie))
* .execute();
* }
* </pre>
* Assuming movie titles being unique, it is possible to update the created movie in the following way:
* <pre>
* {@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();
* }
* </pre>
* The {@link Property} annotation may be used to override the record component name.
* <pre>
* {@code
* public record Movie(String title, String tagline, @Property("releasedYear") long released) {}
* }
* </pre>
* Note that those record components that have {@code null} value will be excluded from the map value.
* <p>
* 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<String, Value> 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
Expand Down Expand Up @@ -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));
}

/**
Expand All @@ -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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions driver/src/test/java/org/neo4j/driver/ObjectMappingTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ void shouldMapValue(Function<Map<String, Value>, 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)),
Expand Down
83 changes: 83 additions & 0 deletions driver/src/test/java/org/neo4j/driver/internal/ValuesTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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<String> 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) {}
}