0

I can find plenty of examples of polymorphic deserialization based on a field within an object:

[
  {
     "type": "Engine",
     "name": "Ford 6.7L",
     "cylinders": 8
  },
  {
     "type": "Tires",
     "name": "Blizzak LM32",
     "season": "winter"
  }
]

But I can't seem to easily put together something that'll use object keys to determine type:

{
  "Engine": {
    "name": "Ford 6.7L",
    "cylinders": 8
  },
  "Tires": {
    "name": "Blizzak LM32",
    "season": "winter"
  }
}

without first parsing the file into a JsonObject and then iterating through that object and re-converting each value back to a string and re-parsing into a class based on the key (and rolling my own method of tracking types per key).

Ideally, I'd like to do something along these lines:

@JsonKey("Engine")
class Engine implements Equipment {
  String name;
  Integer cylinders;
}

@JsonKey("Tires")
class Tires implements Equipment {
  String name;
  String season;
}

And be able to parse the file like this:

Map<String, Equipment> car = gson.fromJson(fileContents, new TypeToken<Map<String, Equipment>>(){}.getType();

This seems like a pretty obvious use case to me. What am I missing?

Allen Luce
  • 7,859
  • 3
  • 40
  • 53
  • You can already parse it as `Map animals` and then `animals.get("dog")`. But as in the answer if you want to keep the JSON valid there can be only one animal per type. You can test it but as it it parsed to a map there can be one object per key any previous type will be overwritten. And if not valid JSON then GSON is not going to help you, not even JsonParser. You need to make parser of your own. – pirho Jul 24 '20 at 10:02
  • I'm surprised people didn't assume unique keys in the top-level object. I've changed the example to make it a little more clear. – Allen Luce Jul 24 '20 at 22:08
  • @pirho How can one already parse it to a map? I could not find a straightforward way of doing this. – Allen Luce Jul 24 '20 at 22:22
  • I am not suprised because there was no mention about it. Then it would be easy as creating a class having these fields per type so "class Vehicle {Engine engine; Tires tires}". – pirho Jul 25 '20 at 10:12
  • That requires a class with contents explicitly defined. My goal is to deserialize to a `Map` – Allen Luce Jul 25 '20 at 20:34

2 Answers2

1

There is nothing good in using object names as key to deserialize polymorphic type. This is leading to having multiple object's with same name being part of parent object (your case). When you would try to deserialize parent JSON object (in future there might be parent object containing attribute's Engine and Tires) you could end up with multiple JSON object's representing this attribute with same name (repeating type name) leading to parser exception.

Deserialization based on type attribute inside JSON object is common and convenient way. You could implement code to work as you expect but it would be not error prone in all cases and therefore JSON parser implementation's expect, in this case, to deserialize polymorphic type by nested type attribute which is error prone and clean way to do so.

Edit: What you are trying to achieve is also against separation of concern (JSON object key is key itself and also type key at same time) while type attribute separate's type responsibility to one of JSON object's attribute's. That is also following KISS principle (keep it stupid simple) and also many of developer's are used to type attribute's in case of polymorphic deserialization.

Allen Luce
  • 7,859
  • 3
  • 40
  • 53
Norbert Dopjera
  • 741
  • 5
  • 18
  • Don't understand downvote, you can read this thread: https://stackoverflow.com/questions/24263904/deserializing-polymorphic-types-with-jackson Jackson is one of most powerful library's in Java for JSON mapper's and doesn't contain solution for that. Only solution's are workaround's to implement custom polymorphic type deducer's etc. Read especially this answer's: https://stackoverflow.com/a/55333395/12053054 https://stackoverflow.com/a/24273269/12053054 – Norbert Dopjera Jul 24 '20 at 00:24
  • Firstly, you didn't even try to answer the question. Also, your reasons for not doing it do not make sense. It's not clear what "parent object containing Dog and Cat" refers to. My top-level object already contains the keys Dog and Cat. By "same name" do you mean the key name in the top-level object or the "name" attribute in the subsidiary object? Repeated keys in a JSON file are already an error. The question you link to in your comment refers to a different situation: attempting to infer a type based on matching attributes of the object itself, not the object that the key refers to. – Allen Luce Jul 24 '20 at 06:13
  • 2
    @AllenLuce I don't see a reason of why this answer got downvoted as it's telling absolutely **right** things. Norbert is trying to tell that `Map` won't work. I'd tell the same. How would such a map contain two "cats"? It just can't do it unless it's a multimap (`Map` or something like `Multimap` from Google Guava). The suggested JSON document can't do that either. – terrorrussia-keeps-killing Jul 24 '20 at 08:25
  • 1
    @AllenLuce if you absolutely must handle the weird `{"Cat":{...},"Dog":{...}}` structure coming from elsewhere (e.g. the JSON format is suggested by an external service you don't have control over), you might provide your code to examine by others so they can share their thoughts on your solution. If you control the format, well, then just use `RuntimeTypeAdapterFactory` from Gson and convert the list to a map (without being able to have two or more "cats") or a multimap right after the JSON document is deserialized. – terrorrussia-keeps-killing Jul 24 '20 at 08:27
  • 1
    "keep it simple, stupid" or "keep it stupid simple" :D I recommend the latter to be used in SO. – pirho Jul 24 '20 at 09:49
  • I've changed the example to make it clear that top-level keys are always to be unique. So there would never be repeated objects. I guess now I'll see how long before someone says "you can't do that! It will never work for cars with multiple engines!" – Allen Luce Jul 24 '20 at 22:20
  • @AllenLuce let me be the first: sure, it won't work. If you'd be more clear on stating the _100% uniqueness_ not providing confusing domain models, we'd be happy to help you from the very beginning. Also, `RuntimeTypeAdapterFactory` I believe you're aware of is open for modification and remains available in the extras at their repository as source code. Did you have a chance to explore it? Its ideas can still be used to fulfill your requirements if you must not use a top-level JSON array that could be converted to a map (thus letting us know you're not running into the X-Y problem). – terrorrussia-keeps-killing Jul 25 '20 at 00:01
0

All you have to do is to implement a custom Map<String, ...> deserializer that will be triggered for maps defined using a special deserializer that's aware of the mapping rules.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@interface JsonKey {

    @Nonnull
    String value();

}
final class JsonKeyMapTypeAdapterFactory<V>
        implements TypeAdapterFactory {

    private final Class<V> superClass;
    private final Map<String, Class<? extends V>> subClasses;
    private final Supplier<? extends Map<String, V>> createMap;

    private JsonKeyMapTypeAdapterFactory(final Class<V> superClass, final Map<String, Class<? extends V>> subClasses,
            final Supplier<? extends Map<String, V>> createMap) {
        this.superClass = superClass;
        this.subClasses = subClasses;
        this.createMap = createMap;
    }

    static <V> Builder<V> build(final Class<V> superClass) {
        return new Builder<>(superClass);
    }

    @Override
    @Nullable
    public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
        if ( !Map.class.isAssignableFrom(typeToken.getRawType()) ) {
            return null;
        }
        final Type type = typeToken.getType();
        if ( !(type instanceof ParameterizedType) ) {
            return null;
        }
        final ParameterizedType parameterizedType = (ParameterizedType) type;
        final Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
        final Type valueType = actualTypeArguments[1];
        if ( !(valueType instanceof Class) ) {
            return null;
        }
        final Class<?> valueClass = (Class<?>) valueType;
        if ( !superClass.isAssignableFrom(valueClass) ) {
            return null;
        }
        final Type keyType = actualTypeArguments[0];
        if ( !(keyType instanceof Class) || keyType != String.class ) {
            throw new IllegalArgumentException(typeToken + " must represent a string-keyed map");
        }
        final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter = subClasses.entrySet()
                .stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> gson.getDelegateAdapter(this, TypeToken.get(e.getValue()))))
                ::get;
        @SuppressWarnings("unchecked")
        final TypeAdapter<T> castTypeAdapter = (TypeAdapter<T>) JsonKeyMapTypeAdapter.create(resolveTypeAdapter, createMap);
        return castTypeAdapter;
    }

    static final class Builder<V> {

        private final Class<V> superClass;

        private final ImmutableMap.Builder<String, Class<? extends V>> subClasses = new ImmutableMap.Builder<>();

        private Supplier<? extends Map<String, V>> createMap = LinkedHashMap::new;

        private Builder(final Class<V> superClass) {
            this.superClass = superClass;
        }

        Builder<V> register(final Class<? extends V> subClass) {
            @Nullable
            final JsonKey jsonKey = subClass.getAnnotation(JsonKey.class);
            if ( jsonKey == null ) {
                throw new IllegalArgumentException(subClass + " must be annotated with " + JsonKey.class);
            }
            return register(jsonKey.value(), subClass);
        }

        Builder<V> register(final String key, final Class<? extends V> subClass) {
            if ( !superClass.isAssignableFrom(subClass) ) {
                throw new IllegalArgumentException(subClass + " must be a subclass of " + superClass);
            }
            subClasses.put(key, subClass);
            return this;
        }

        Builder<V> createMapWith(final Supplier<? extends Map<String, V>> createMap) {
            this.createMap = createMap;
            return this;
        }

        TypeAdapterFactory create() {
            return new JsonKeyMapTypeAdapterFactory<>(superClass, subClasses.build(), createMap);
        }

    }

    private static final class JsonKeyMapTypeAdapter<V>
            extends TypeAdapter<Map<String, V>> {

        private final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter;
        private final Supplier<? extends Map<String, V>> createMap;

        private JsonKeyMapTypeAdapter(final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter,
                final Supplier<? extends Map<String, V>> createMap) {
            this.resolveTypeAdapter = resolveTypeAdapter;
            this.createMap = createMap;
        }

        private static <V> TypeAdapter<Map<String, V>> create(final Function<? super String, ? extends TypeAdapter<? extends V>> resolveTypeAdapter,
                final Supplier<? extends Map<String, V>> createMap) {
            return new JsonKeyMapTypeAdapter<>(resolveTypeAdapter, createMap)
                    .nullSafe();
        }

        @Override
        public void write(final JsonWriter out, final Map<String, V> value) {
            throw new UnsupportedOperationException();
        }

        @Override
        public Map<String, V> read(final JsonReader in)
                throws IOException {
            in.beginObject();
            final Map<String, V> map = createMap.get();
            while ( in.hasNext() ) {
                final String key = in.nextName();
                @Nullable
                final TypeAdapter<? extends V> typeAdapter = resolveTypeAdapter.apply(key);
                if ( typeAdapter == null ) {
                    throw new JsonParseException("Unknown key " + key + " at " + in.getPath());
                }
                final V value = typeAdapter.read(in);
                @Nullable
                final V replaced = map.put(key, value);
                if ( replaced != null ) {
                    throw new JsonParseException(value + " duplicates " + replaced + " using " + key);
                }
            }
            in.endObject();
            return map;
        }

    }

}
private static final Gson gson = new GsonBuilder()
        .disableHtmlEscaping()
        .registerTypeAdapterFactory(JsonKeyMapTypeAdapterFactory.build(Equipment.class)
                .register(Engine.class)
                .register(Tires.class)
                .create()
        )
        .create();

The Gson object above will deserialize your JSON document to a map that is toString-ed like this (assuming Lombok is used for toString):

{Engine=Engine(name=Ford 6.7L, cylinders=8), Tires=Tires(name=Blizzak LM32, season=winter)}