-
Notifications
You must be signed in to change notification settings - Fork 30
Support collections nested in Map fields during (de)serialization #189
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| } | ||
| 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)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Routing map values through the full
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in c8f5e80. Reproduced both cases first:
New coverage: |
||
| } | ||
| return map; | ||
| } | ||
|
|
@@ -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) { | ||
|
|
@@ -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; | ||
|
|
||
| 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()); | ||
| } | ||
| } | ||
| } |
| 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) { | ||
| } |
| 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) { | ||
| } |
There was a problem hiding this comment.
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 asString, ignoring the map's first type argument (Map<Integer,X>→Stringkeys). Both pre-date this PR; noting since they're in the 'Map fields' scope.There was a problem hiding this comment.
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:
newMapInstancenow 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); plainMap(and unknown abstractions) still fall back toHashMap. Repro before the fix: aLinkedHashMapfield threwIllegalArgumentException: Can not set java.util.LinkedHashMap field ... to java.util.HashMap. Covered byMapNestedCollectionsTests#roundTripConcreteAndSortedMapFieldTypes(POJO fields) and theTreeMaprecord component inRecordMapNestedCollectionsTests.I've deliberately deferred non-String keys (
Map<Integer,X>): SurrealDB object keys are strings on the wire (Entry.getKey()returnsString), and the write path already stringifies keys viaentry.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.