Skip to content

Support collections nested in Map fields during (de)serialization#189

Open
welpie21 wants to merge 3 commits into
surrealdb:mainfrom
welpie21:fix/map-nested-collections
Open

Support collections nested in Map fields during (de)serialization#189
welpie21 wants to merge 3 commits into
surrealdb:mainfrom
welpie21:fix/map-nested-collections

Conversation

@welpie21

Copy link
Copy Markdown

The bug

ValueClassConverter (the read path used by select(Class, ...), create(Class, ...), Value.get(Class), etc.) only handled objects and scalars as Map entry values — the // todo - array support inside maps at the old line 204. It also only extracted the raw value type of the map (secondTypeArgumentRaw), so nested generics lost their element-type information.

Reproduction (before the fix)

POJO field shape Failure on read-back
Map<String, List<String>> SurrealException: Unsupported value: ['apple', 'banana'] (thrown from convertSingleValue)
Map<String, List<Pojo>> SurrealException: Unsupported value: [{ first: 'Ada', last: 'Lovelace' }]
Map<String, Map<String, String>> SurrealException: Failed to create instance of ... caused by NoSuchMethodException: java.util.Map.<init>() (the object branch tried to instantiate the raw Map interface as a POJO)

The write path (ValueBuilder) already recurses through collections and maps correctly — verified by a write-only test — so this is a read-path-only fix. Records use the same convertValueToType path and were equally affected.

The fix

In the Map branch of ValueClassConverter.convertValueToType, keep the full generic Type of the map values (new secondTypeArgument, replacing secondTypeArgumentRaw) and route every entry value through convertValueToType(entryValue, valueType, valueGenericType) — the same recursion used for top-level fields. This makes arrays inside maps hydrate with correct element types, and nested maps recurse into the existing Map branch instead of being treated as POJOs. The TODO is resolved and removed.

No changes to the cached per-class field metadata (SurrealFieldNames) were needed; the fix is purely in the value-conversion recursion.

Tests

  • MapNestedCollectionsTests (main suite): round-trip create → select for Map<String, List<String>>, Map<String, List<Name>>, Map<String, Map<String, String>>, plus a write-path-only structural check.
  • RecordMapNestedCollectionsTests (record suite): round-trip of a record with all three shapes.
  • Full ./gradlew test and ./gradlew recordTest pass.

🤖 Generated with Claude Code

welpie21 and others added 2 commits July 10, 2026 14:47
Deserializing into a Map field only handled object and scalar entry
values: arrays threw "Unsupported value" and nested maps failed with
NoSuchMethodException (java.util.Map.<init>()), because the converter
only kept the raw value type of the map. Route map entry values through
convertValueToType with the full generic Type of the map values, so
Map<String, List<String>>, Map<String, List<Pojo>> and
Map<String, Map<String, String>> hydrate through the same recursion as
top-level fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@welpie21 welpie21 marked this pull request as ready for review July 10, 2026 14:29

@emmanuel-keller emmanuel-keller left a comment

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.

Review — approve with nits.

The fix is correct for the three documented shapes, and it's actually a strict improvement: scalar-valued maps (Map<String,Integer>, Map<String,Instant>, …) now respect their declared type instead of coming back as Long/Double/ZonedDateTime. No Rust touched, so the native-handle model is unchanged.

Test gaps worth closing: a scalar-leaf typed case (e.g. Map<String,Integer>) to lock in that new behaviour; empty-map / empty-inner-list / null-entry-value; and the deeper shapes noted inline (either fix or add a CHANGELOG caveat).

} 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.

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.

convertArrayElement previously hydrated object elements with the raw
element class only (no Map/Optional/Value handling) and reused the
outer element type when recursing into nested arrays, so one level
deeper than the fixed cases still failed on read even though
ValueBuilder serializes it fine: Map<String, List<Map<String, String>>>
threw NoSuchMethodException (java.util.Map.<init>()) and
Map<String, List<List<Pojo>>> threw NoSuchMethodException
(java.util.List.<init>()). Typed elements now delegate to
convertValueToType, whose isArray branch descends one generic type
argument per level, unifying both recursion paths; untyped (raw)
collections keep the historical type-agnostic behaviour. As a side
effect, scalar elements now hydrate with their declared element type
(List<Integer> holds Integer, not Long).

Deserialized maps also now honour the declared map implementation
instead of always constructing HashMap, which failed with
IllegalArgumentException on fields declared as LinkedHashMap, TreeMap,
SortedMap, and the like. Concrete classes are instantiated directly;
the JDK map interfaces (SortedMap/NavigableMap, ConcurrentMap,
ConcurrentNavigableMap) get a matching implementation; plain Map still
gets a HashMap. Non-String map keys (Map<Integer, X>) remain
unsupported: object keys are strings on the wire and the write path
stringifies keys, so key conversion is deferred as a separate feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants