Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## [Unreleased]
- Support collections nested inside `Map` fields when deserializing POJOs and records: map entry values that are arrays (e.g. `Map<String, List<String>>`, `Map<String, List<Pojo>>`) previously threw `Unsupported value`, and nested maps (`Map<String, Map<String, String>>`) failed trying to instantiate the `Map` interface. Entry values now hydrate through the same type-aware recursion as top-level fields, using the map's full generic value type. Array elements route through that recursion too, so deeper nestings such as `Map<String, List<Map<String, String>>>` and `Map<String, List<List<Pojo>>>` also round-trip (nested arrays descend one generic type argument per level), and scalar elements hydrate with their declared element type (e.g. `List<Integer>` now holds `Integer`, not `Long`) [#189](https://github.com/surrealdb/surrealdb.java/pull/189).
- Honour the declared map implementation when deserializing map fields and record components: concrete types (`LinkedHashMap`, `TreeMap`, ...) are instantiated directly, and the `SortedMap`/`NavigableMap`, `ConcurrentMap`, and `ConcurrentNavigableMap` interfaces get a matching implementation. Previously the converter always produced a `HashMap`, which failed with `IllegalArgumentException` on such fields; plain `Map` fields still receive a `HashMap` [#189](https://github.com/surrealdb/surrealdb.java/pull/189).

## [2.1.2] - 2026-06-24
- Add full geometry-type support for reading and writing all seven types — Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, and GeometryCollection — via `Geometry` type-discrimination accessors (`getType()`, `isPolygon()`, …), readers returning `java.awt.geom.Point2D.Double` coordinates (x = longitude, y = latitude), and factory methods that serialize through `create`/`update` content and bound parameters. Also fixes an `UnsatisfiedLinkError` thrown when a `Geometry` was finalized (the native `deleteInstance` had no matching Rust symbol) [#183](https://github.com/surrealdb/surrealdb.java/pull/183).
- Add `Surreal.kill(String)` / `Surreal.kill(java.util.UUID)` to terminate a live query by id, and `LiveStream.getQueryId()` to read the live-query UUID immediately, before the first notification; `selectLive` now starts the subscription through the public `LIVE SELECT` query path so the id is available up front. `kill()` stops notifications but does not close a local `LiveStream` — use `LiveStream.close()` to release a blocked `next()` [#184](https://github.com/surrealdb/surrealdb.java/pull/184).
Expand Down
73 changes: 51 additions & 22 deletions src/main/java/com/surrealdb/ValueClassConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -193,20 +193,17 @@ private static java.lang.Object convertValueToType(final Value value, final Clas
}
if (value.isObject()) {
if (Map.class.isAssignableFrom(type)) {
final Class<?> valueType = secondTypeArgumentRaw(genericType);
// Keep the full generic Type of the map values so that nested
// collections (List<X>, Map<K, V>, ...) hydrate their element
// types through the same recursion as top-level fields.
final Type valueGenericType = secondTypeArgument(genericType);
final Class<?> valueType = rawType(valueGenericType);
if (valueType == null) {
throw new SurrealException("Unsupported map type for: " + genericType);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor (pre-existing pattern): always constructs new HashMap<>(), so a field declared as a concrete/ordered map type (TreeMap, LinkedHashMap, SortedMap) will fail on set. Also keys are always taken as String, ignoring the map's first type argument (Map<Integer,X>String keys). Both pre-date this PR; noting since they're in the 'Map fields' scope.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the map-instantiation half in c8f5e80: newMapInstance now instantiates concrete declared types (LinkedHashMap, TreeMap, ...) via their no-arg constructor and picks a matching implementation for the JDK map interfaces (SortedMap/NavigableMapTreeMap, ConcurrentMapConcurrentHashMap, ConcurrentNavigableMapConcurrentSkipListMap); plain Map (and unknown abstractions) still fall back to HashMap. Repro before the fix: a LinkedHashMap field threw IllegalArgumentException: Can not set java.util.LinkedHashMap field ... to java.util.HashMap. Covered by MapNestedCollectionsTests#roundTripConcreteAndSortedMapFieldTypes (POJO fields) and the TreeMap record component in RecordMapNestedCollectionsTests.

I've deliberately deferred non-String keys (Map<Integer,X>): SurrealDB object keys are strings on the wire (Entry.getKey() returns String), and the write path already stringifies keys via entry.getKey().toString(), so honouring the first type argument means parsing strings back into arbitrary key types (numbers, UUIDs, enums, ...) with its own failure modes — that feels like a separate feature/PR rather than part of this fix. Happy to file an issue for it if you agree.

final Map<String, java.lang.Object> map = new HashMap<>();
final Map<String, java.lang.Object> map = newMapInstance(type);
for (final Entry mapEntry : value.getObject()) {
final String entryKey = mapEntry.getKey();
final Value entryValue = mapEntry.getValue();
// todo - array support inside maps
if (entryValue.isObject()) {
map.put(entryKey, convert(valueType, entryValue.getObject()));
} else {
map.put(entryKey, convertSingleValue(entryValue));
}
map.put(mapEntry.getKey(), convertValueToType(mapEntry.getValue(), valueType, valueGenericType));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Routing map values through the full convertValueToType recursion is the right fix. One scope caveat: the sibling convertArrayElement was left unchanged and still lacks Map/Optional/type-aware-scalar handling and reuses the outer elementType when recursing into nested arrays. So one level deeper than the fixed cases still throws on read even though ValueBuilder serializes it fine — a genuine round-trip asymmetry. Examples that still fail: Map<String,List<Map<String,String>>> and Map<String,List<List<Pojo>>>. Cleanest fix would be to have convertArrayElement delegate to convertValueToType too (descending firstTypeArgument for nested arrays), unifying both paths.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c8f5e80. Reproduced both cases first: Map<String,List<Map<String,String>>> failed with NoSuchMethodException: java.util.Map.<init>() (object elements went through convert(elementType, ...)) and Map<String,List<List<Pojo>>> with NoSuchMethodException: java.util.List.<init>() (nested arrays reused the outer elementType). In both repros create() succeeded and only the read-back conversion threw, confirming the round-trip asymmetry — the fix is read-path only.

convertArrayElement now delegates typed elements to convertValueToType, whose isArray branch descends one firstTypeArgument per level, so both recursion paths are unified at any depth (Map/Optional/Value/type-aware scalars included). When no generic element type is available (raw collections) the historical type-agnostic behaviour is kept. One deliberate side effect of the unification: typed scalar elements now hydrate with their declared element type, e.g. List<Integer> holds Integer instead of Long — covered by a test and noted in the changelog.

New coverage: MapNestedCollectionsTests#roundTripMapOfListsOfMaps, #roundTripMapOfListsOfPojoLists (POJO path) and RecordMapNestedCollectionsTests#roundTripDeeperNestingAndConcreteMapTypes (record path). Full test + recordTest suites are green.

}
return map;
}
Expand All @@ -232,21 +229,53 @@ private static java.lang.Object convertValueToType(final Value value, final Clas

private static java.lang.Object convertArrayElement(final Value value, final Class<?> elementType,
final Type elementGenericType) throws ReflectiveOperationException {
if (value.isObject()) {
if (elementType == null) {
if (elementType == null) {
// No generic element information (raw or untyped collection):
// objects cannot be hydrated, nested arrays stay untyped lists,
// and scalars keep the historical type-agnostic conversion.
if (value.isObject()) {
throw new SurrealException("Unsupported element type for array");
}
return convert(elementType, value.getObject());
if (value.isArray()) {
final List<java.lang.Object> nested = new ArrayList<>();
for (final Value v : value.getArray()) {
nested.add(convertArrayElement(v, null, null));
}
return nested;
}
return convertSingleValue(value);
}
if (value.isArray()) {
final List<java.lang.Object> nested = new ArrayList<>();
for (final Value v : value.getArray()) {
nested.add(convertArrayElement(v, elementType, elementGenericType));
// Delegate to the shared recursion so maps, optionals, and type-aware
// scalars behave the same at any nesting depth, and nested arrays
// descend one type argument per level through the isArray branch of
// convertValueToType instead of reusing the outer element type.
return convertValueToType(value, elementType, elementGenericType);
}

// Honour the declared map type: instantiate concrete classes
// (LinkedHashMap, TreeMap, ...) so Field.set and the record constructor
// accept the instance, and pick a matching implementation for the JDK
// map interfaces. Plain Map (and unknown abstractions) fall back to
// HashMap, preserving the historical behaviour.
@SuppressWarnings("unchecked")
private static Map<String, java.lang.Object> newMapInstance(final Class<?> type)
throws ReflectiveOperationException {
if (type.isInterface() || Modifier.isAbstract(type.getModifiers())) {
if (java.util.concurrent.ConcurrentNavigableMap.class.isAssignableFrom(type)) {
return new java.util.concurrent.ConcurrentSkipListMap<>();
}
if (java.util.concurrent.ConcurrentMap.class.isAssignableFrom(type)) {
return new java.util.concurrent.ConcurrentHashMap<>();
}
return nested;
if (java.util.SortedMap.class.isAssignableFrom(type)) {
return new java.util.TreeMap<>();
}
return new HashMap<>();
}
// Preserve the historical behaviour: scalar array elements are type-agnostic.
return convertSingleValue(value);
if (HashMap.class.equals(type)) {
return new HashMap<>();
}
return (Map<String, java.lang.Object>) type.getDeclaredConstructor().newInstance();
}

private static Type firstTypeArgument(final Type genericType) {
Expand All @@ -263,11 +292,11 @@ private static Class<?> firstTypeArgumentRaw(final Type genericType) {
return rawType(firstTypeArgument(genericType));
}

private static Class<?> secondTypeArgumentRaw(final Type genericType) {
private static Type secondTypeArgument(final Type genericType) {
if (genericType instanceof ParameterizedType) {
final Type[] args = ((ParameterizedType) genericType).getActualTypeArguments();
if (args.length > 1) {
return rawType(args[1]);
return args[1];
}
}
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.surrealdb;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;

import com.surrealdb.pojos.CatalogRecord;
import com.surrealdb.pojos.DepotRecord;
import com.surrealdb.pojos.NameRecord;

public class RecordMapNestedCollectionsTests {

@Test
void roundTripNestedCollectionsInMaps() {
try (final Surreal surreal = new Surreal()) {
surreal.connect("memory").useNs("test_ns").useDb("test_db");

final Map<String, List<String>> categories = new HashMap<>();
categories.put("fruits", Arrays.asList("apple", "banana"));
categories.put("vegetables", Arrays.asList("carrot"));

final Map<String, List<NameRecord>> teams = new HashMap<>();
teams.put("engineering", Arrays.asList(new NameRecord("Tobie", "Morgan Hitchcock")));
teams.put("design", Arrays.asList(new NameRecord("Ada", "Lovelace"), new NameRecord("Grace", "Hopper")));

final Map<String, String> fr = new HashMap<>();
fr.put("hello", "bonjour");
final Map<String, Map<String, String>> translations = new HashMap<>();
translations.put("fr", fr);

final CatalogRecord catalog = new CatalogRecord(null, categories, teams, translations);
final CatalogRecord created = surreal.create(CatalogRecord.class, "catalog", catalog).get(0);
assertNotNull(created.id());

final Optional<CatalogRecord> selected = surreal.select(CatalogRecord.class, created.id());
assertTrue(selected.isPresent());
assertEquals(categories, selected.get().categories());
assertEquals(teams, selected.get().teams());
assertEquals(translations, selected.get().translations());
}
}

@Test
void roundTripDeeperNestingAndConcreteMapTypes() {
try (final Surreal surreal = new Surreal()) {
surreal.connect("memory").useNs("test_ns").useDb("test_db");

final Map<String, String> shelf = new HashMap<>();
shelf.put("sku", "A-1");
final Map<String, List<Map<String, String>>> sections = new HashMap<>();
sections.put("hardware", Arrays.asList(shelf));

final Map<String, List<List<NameRecord>>> shifts = new HashMap<>();
shifts.put("monday", Arrays.asList(Arrays.asList(new NameRecord("Ada", "Lovelace")),
Arrays.asList(new NameRecord("Grace", "Hopper"))));

final java.util.TreeMap<String, Long> scores = new java.util.TreeMap<>();
scores.put("alice", 3L);
scores.put("bob", 5L);

final DepotRecord depot = new DepotRecord(null, sections, shifts, scores);
final DepotRecord created = surreal.create(DepotRecord.class, "depot", depot).get(0);
assertNotNull(created.id());

final Optional<DepotRecord> selected = surreal.select(DepotRecord.class, created.id());
assertTrue(selected.isPresent());
assertEquals(sections, selected.get().sections());
assertEquals(shifts, selected.get().shifts());
assertEquals(scores, selected.get().scores());
assertEquals(java.util.TreeMap.class, selected.get().scores().getClass());
}
}
}
10 changes: 10 additions & 0 deletions src/recordTest/java/com/surrealdb/pojos/CatalogRecord.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.surrealdb.pojos;

import com.surrealdb.RecordId;

import java.util.List;
import java.util.Map;

public record CatalogRecord(RecordId id, Map<String, List<String>> categories, Map<String, List<NameRecord>> teams,
Map<String, Map<String, String>> translations) {
}
11 changes: 11 additions & 0 deletions src/recordTest/java/com/surrealdb/pojos/DepotRecord.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.surrealdb.pojos;

import com.surrealdb.RecordId;

import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public record DepotRecord(RecordId id, Map<String, List<Map<String, String>>> sections,
Map<String, List<List<NameRecord>>> shifts, TreeMap<String, Long> scores) {
}
Loading
Loading