Support collections nested in Map fields during (de)serialization#189
Support collections nested in Map fields during (de)serialization#189welpie21 wants to merge 3 commits into
Conversation
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>
emmanuel-keller
left a comment
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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/NavigableMap → TreeMap, ConcurrentMap → ConcurrentHashMap, ConcurrentNavigableMap → ConcurrentSkipListMap); 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>
The bug
ValueClassConverter(the read path used byselect(Class, ...),create(Class, ...),Value.get(Class), etc.) only handled objects and scalars asMapentry values — the// todo - array support inside mapsat 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)
Map<String, List<String>>SurrealException: Unsupported value: ['apple', 'banana'](thrown fromconvertSingleValue)Map<String, List<Pojo>>SurrealException: Unsupported value: [{ first: 'Ada', last: 'Lovelace' }]Map<String, Map<String, String>>SurrealException: Failed to create instance of ...caused byNoSuchMethodException: java.util.Map.<init>()(the object branch tried to instantiate the rawMapinterface 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 sameconvertValueToTypepath and were equally affected.The fix
In the
Mapbranch ofValueClassConverter.convertValueToType, keep the full genericTypeof the map values (newsecondTypeArgument, replacingsecondTypeArgumentRaw) and route every entry value throughconvertValueToType(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 existingMapbranch 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 forMap<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../gradlew testand./gradlew recordTestpass.🤖 Generated with Claude Code