From 3579be0d0f81be74fef5d6955952023193ce52c4 Mon Sep 17 00:00:00 2001 From: "Kim, Joo Hyuk" Date: Sat, 26 Aug 2023 09:36:19 +0900 Subject: [PATCH] Add support for new `JsonTypeInfo.Id.SIMPLE_NAME` polymorphic type id option (#4065) --- .../jsontype/impl/SimpleNameIdResolver.java | 205 +++++++++++++ .../jsontype/impl/StdTypeResolverBuilder.java | 2 + .../JsonTypeInfoSimpleClassName4061Test.java | 288 ++++++++++++++++++ 3 files changed, 495 insertions(+) create mode 100644 src/main/java/com/fasterxml/jackson/databind/jsontype/impl/SimpleNameIdResolver.java create mode 100644 src/test/java/com/fasterxml/jackson/databind/jsontype/JsonTypeInfoSimpleClassName4061Test.java diff --git a/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/SimpleNameIdResolver.java b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/SimpleNameIdResolver.java new file mode 100644 index 0000000000..94724f5f64 --- /dev/null +++ b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/SimpleNameIdResolver.java @@ -0,0 +1,205 @@ +package com.fasterxml.jackson.databind.jsontype.impl; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.DatabindContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.cfg.MapperConfig; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link com.fasterxml.jackson.databind.jsontype.TypeIdResolver} implementation + * that converts between (JSON) Strings and simple Java class names. + * + * @since 2.16 + */ +public class SimpleNameIdResolver + extends TypeIdResolverBase +{ + protected final MapperConfig _config; + + /** + * Mappings from class name to type id, used for serialization. + *

+ * Since lazily constructed will require synchronization (either internal + * by type, or external) + */ + protected final ConcurrentHashMap _typeToId; + + /** + * Mappings from type id to JavaType, used for deserialization. + *

+ * Eagerly constructed, not modified, can use regular unsynchronized {@link Map}. + */ + protected final Map _idToType; + + protected final boolean _caseInsensitive; + + protected SimpleNameIdResolver(MapperConfig config, JavaType baseType, + ConcurrentHashMap typeToId, + HashMap idToType) + { + super(baseType, config.getTypeFactory()); + _config = config; + _typeToId = typeToId; + _idToType = idToType; + _caseInsensitive = config.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_VALUES); + } + + public static SimpleNameIdResolver construct(MapperConfig config, JavaType baseType, + Collection subtypes, boolean forSer, boolean forDeser) + { + // sanity check + if (forSer == forDeser) throw new IllegalArgumentException(); + + final ConcurrentHashMap typeToId; + final HashMap idToType; + + if (forSer) { + // Only need Class-to-id for serialization; but synchronized since may be + // lazily built (if adding type-id-mappings dynamically) + typeToId = new ConcurrentHashMap<>(); + idToType = null; + } else { + idToType = new HashMap<>(); + // 14-Apr-2016, tatu: Apparently needed for special case of `defaultImpl`; + // see [databind#1198] for details: but essentially we only need room + // for a single value. + typeToId = new ConcurrentHashMap<>(4); + } + final boolean caseInsensitive = config.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_VALUES); + + if (subtypes != null) { + for (NamedType t : subtypes) { + // no name? Need to figure out default; for now, let's just + // use non-qualified class name + Class cls = t.getType(); + String id = t.hasName() ? t.getName() : _defaultTypeId(cls); + if (forSer) { + typeToId.put(cls.getName(), id); + } + if (forDeser) { + // [databind#1983]: for case-insensitive lookups must canonicalize: + if (caseInsensitive) { + id = id.toLowerCase(); + } + // One more problem; sometimes we have same name for multiple types; + // if so, use most specific + JavaType prev = idToType.get(id); // lgtm [java/dereferenced-value-may-be-null] + if (prev != null) { // Can only override if more specific + if (cls.isAssignableFrom(prev.getRawClass())) { // nope, more generic (or same) + continue; + } + } + idToType.put(id, config.constructType(cls)); + } + } + } + return new SimpleNameIdResolver(config, baseType, typeToId, idToType); + } + + @Override + public JsonTypeInfo.Id getMechanism() { return JsonTypeInfo.Id.SIMPLE_NAME; } + + @Override + public String idFromValue(Object value) { + return idFromClass(value.getClass()); + } + + protected String idFromClass(Class clazz) + { + if (clazz == null) { + return null; + } + // NOTE: although we may need to let `TypeModifier` change actual type to use + // for id, we can use original type as key for more efficient lookup: + final String key = clazz.getName(); + String name = _typeToId.get(key); + + if (name == null) { + // 29-Nov-2019, tatu: As per test in `TestTypeModifierNameResolution` somehow + // we need to do this odd piece here which seems unnecessary but isn't. + Class cls = _typeFactory.constructType(clazz).getRawClass(); + // 24-Feb-2011, tatu: As per [JACKSON-498], may need to dynamically look up name + // can either throw an exception, or use default name... + if (_config.isAnnotationProcessingEnabled()) { + BeanDescription beanDesc = _config.introspectClassAnnotations(cls); + name = _config.getAnnotationIntrospector().findTypeName(beanDesc.getClassInfo()); + } + if (name == null) { + // And if still not found, let's choose default? + name = _defaultTypeId(cls); + } + _typeToId.put(key, name); + } + return name; + } + + @Override + public String idFromValueAndType(Object value, Class type) { + // 18-Jan-2013, tatu: We may be called with null value occasionally + // it seems; nothing much we can figure out that way. + if (value == null) { + return idFromClass(type); + } + return idFromValue(value); + } + + @Override + public JavaType typeFromId(DatabindContext context, String id) { + return _typeFromId(id); + } + + protected JavaType _typeFromId(String id) { + // [databind#1983]: for case-insensitive lookups must canonicalize: + if (_caseInsensitive) { + id = id.toLowerCase(); + } + // Now: if no type is found, should we try to locate it by + // some other means? (specifically, if in same package as base type, + // could just try Class.forName) + // For now let's not add any such workarounds; can add if need be + return _idToType.get(id); + } + + @Override + public String getDescForKnownTypeIds() { + // 05-May-2020, tatu: As per [databind#1919], only include ids for + // non-abstract types + final TreeSet ids = new TreeSet<>(); + for (Map.Entry entry : _idToType.entrySet()) { + if (entry.getValue().isConcrete()) { + ids.add(entry.getKey()); + } + } + return ids.toString(); + } + + @Override + public String toString() { + return String.format("[%s; id-to-type=%s]", getClass().getName(), _idToType); + } + + /* + /********************************************************* + /* Helper methods + /********************************************************* + */ + + /** + * If no name was explicitly given for a class, we will just + * use simple class name + */ + protected static String _defaultTypeId(Class cls) + { + String n = cls.getName(); + int ix = Math.max(n.lastIndexOf('.'), n.lastIndexOf('$')); + return (ix < 0) ? n : n.substring(ix+1); + } +} diff --git a/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/StdTypeResolverBuilder.java b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/StdTypeResolverBuilder.java index 43580d0ec4..ed6ff31fd9 100644 --- a/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/StdTypeResolverBuilder.java +++ b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/StdTypeResolverBuilder.java @@ -368,6 +368,8 @@ protected TypeIdResolver idResolver(MapperConfig config, return ClassNameIdResolver.construct(baseType, config, subtypeValidator); case MINIMAL_CLASS: return MinimalClassNameIdResolver.construct(baseType, config, subtypeValidator); + case SIMPLE_NAME: + return SimpleNameIdResolver.construct(config, baseType, subtypes, forSer, forDeser); case NAME: return TypeNameIdResolver.construct(config, baseType, subtypes, forSer, forDeser); case NONE: // hmmh. should never get this far with 'none' diff --git a/src/test/java/com/fasterxml/jackson/databind/jsontype/JsonTypeInfoSimpleClassName4061Test.java b/src/test/java/com/fasterxml/jackson/databind/jsontype/JsonTypeInfoSimpleClassName4061Test.java new file mode 100644 index 0000000000..d5cc00eda6 --- /dev/null +++ b/src/test/java/com/fasterxml/jackson/databind/jsontype/JsonTypeInfoSimpleClassName4061Test.java @@ -0,0 +1,288 @@ +package com.fasterxml.jackson.databind.jsontype; + +import com.fasterxml.jackson.annotation.*; +import com.fasterxml.jackson.databind.BaseMapTest; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; +import com.fasterxml.jackson.databind.jsontype.impl.SimpleNameIdResolver; +import java.util.ArrayList; +import java.util.List; + +/** + * Test for + * [databind#4061] Add JsonTypeInfo.Id.SIMPLE_NAME + * + * @since 2.16 + */ +public class JsonTypeInfoSimpleClassName4061Test extends BaseMapTest +{ + + @JsonTypeInfo( + use = JsonTypeInfo.Id.SIMPLE_NAME) + @JsonSubTypes({ + @JsonSubTypes.Type(value = InnerSub4061A.class), + @JsonSubTypes.Type(value = InnerSub4061B.class) + }) + static class InnerSuper4061 { } + + static class InnerSub4061A extends InnerSuper4061 { } + + static class InnerSub4061B extends InnerSuper4061 { } + + @JsonTypeInfo( + use = JsonTypeInfo.Id.MINIMAL_CLASS) + @JsonSubTypes({ + @JsonSubTypes.Type(value = MinimalInnerSub4061A.class), + @JsonSubTypes.Type(value = MinimalInnerSub4061B.class) + }) + static class MinimalInnerSuper4061 { } + + static class MinimalInnerSub4061A extends MinimalInnerSuper4061 { } + + static class MinimalInnerSub4061B extends MinimalInnerSuper4061 { } + + @JsonTypeInfo( + use = JsonTypeInfo.Id.SIMPLE_NAME) + @JsonSubTypes({ + @JsonSubTypes.Type(value = MixedSub4061A.class), + @JsonSubTypes.Type(value = MixedSub4061B.class) + }) + static class MixedSuper4061 { } + + @JsonTypeInfo( + use = JsonTypeInfo.Id.MINIMAL_CLASS) + @JsonSubTypes({ + @JsonSubTypes.Type(value = MixedMinimalSub4061A.class), + @JsonSubTypes.Type(value = MixedMinimalSub4061B.class) + }) + static class MixedMinimalSuper4061 { } + + static class Root { + @JsonMerge + public MergeChild child; + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.SIMPLE_NAME) + @JsonSubTypes({ + @JsonSubTypes.Type(value = MergeChildA.class, name = "MergeChildA"), + @JsonSubTypes.Type(value = MergeChildB.class, name = "MergeChildB") + }) + static abstract class MergeChild { + } + + static class MergeChildA extends MergeChild { + public String name; + } + + static class MergeChildB extends MergeChild { + public String code; + } + + static class PolyWrapperForAlias { + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.WRAPPER_ARRAY) + @JsonSubTypes({ + @JsonSubTypes.Type(value = AliasBean.class,name = "ab")}) + public Object value; + + protected PolyWrapperForAlias() { } + + public PolyWrapperForAlias(Object v) { value = v; } + } + + static class AliasBean { + @JsonAlias({ "nm", "Name" }) + public String name; + int _xyz; + int _a; + + @JsonCreator + public AliasBean(@JsonProperty("a") @JsonAlias("A") int a) { + _a = a; + } + + @JsonAlias({ "Xyz" }) + public void setXyz(int x) { + _xyz = x; + } + } + + @JsonTypeInfo( + use = JsonTypeInfo.Id.SIMPLE_NAME) + @JsonSubTypes({ + @JsonSubTypes.Type(value = DuplicateSubClass.class), + @JsonSubTypes.Type(value = com.fasterxml.jackson.databind.jsontype.DuplicateSubClass.class) + }) + static class DuplicateSuperClass { } + + static class DuplicateSubClass extends DuplicateSuperClass { } + + /* + /********************************************************** + /* Unit tests + /********************************************************** + */ + + private final ObjectMapper MAPPER = newJsonMapper(); + + // inner class that has contains dollar sign + public void testInnerClass() throws Exception + { + String jsonStr = a2q("{'@type':'InnerSub4061A'}"); + + // ser + assertEquals(jsonStr, MAPPER.writeValueAsString(new InnerSub4061A())); + + // deser <- breaks! + InnerSuper4061 bean = MAPPER.readValue(jsonStr, InnerSuper4061.class); + assertType(bean, InnerSuper4061.class); + } + + // inner class that has contains dollar sign + public void testMinimalInnerClass() throws Exception + { + String jsonStr = a2q("{'@c':'.JsonTypeInfoSimpleClassName4061Test$MinimalInnerSub4061A'}"); + + // ser + assertEquals(jsonStr, MAPPER.writeValueAsString(new MinimalInnerSub4061A())); + + // deser <- breaks! + MinimalInnerSuper4061 bean = MAPPER.readValue(jsonStr, MinimalInnerSuper4061.class); + assertType(bean, MinimalInnerSuper4061.class); + assertNotNull(bean); + } + + // Basic : non-inner class, without dollar sign + public void testBasicClass() throws Exception + { + String jsonStr = a2q("{'@type':'BasicSub4061A'}"); + + // ser + assertEquals(jsonStr, MAPPER.writeValueAsString(new BasicSub4061A())); + + // deser + BasicSuper4061 bean = MAPPER.readValue(jsonStr, BasicSuper4061.class); + assertType(bean, BasicSuper4061.class); + assertType(bean, BasicSub4061A.class); + } + + // Mixed SimpleClassName : parent as inner, subtype as basic + public void testMixedClass() throws Exception + { + String jsonStr = a2q("{'@type':'MixedSub4061A'}"); + + // ser + assertEquals(jsonStr, MAPPER.writeValueAsString(new MixedSub4061A())); + + // deser + MixedSuper4061 bean = MAPPER.readValue(jsonStr, MixedSuper4061.class); + assertType(bean, MixedSuper4061.class); + assertType(bean, MixedSub4061A.class); + } + + // Mixed MinimalClass : parent as inner, subtype as basic + public void testMixedMinimalClass() throws Exception + { + String jsonStr = a2q("{'@c':'.MixedMinimalSub4061A'}"); + + // ser + assertEquals(jsonStr, MAPPER.writeValueAsString(new MixedMinimalSub4061A())); + + // deser + MixedMinimalSuper4061 bean = MAPPER.readValue(jsonStr, MixedMinimalSuper4061.class); + assertType(bean, MixedMinimalSuper4061.class); + assertType(bean, MixedMinimalSub4061A.class); + } + + public void testPolymorphicNewObject() throws Exception + { + String jsonStr = "{\"child\": { \"@type\": \"MergeChildA\", \"name\": \"I'm child A\" }}"; + + Root root = MAPPER.readValue(jsonStr, Root.class); + + assertTrue(root.child instanceof MergeChildA); + assertEquals("I'm child A", ((MergeChildA) root.child).name); + } + + // case insenstive type name + public void testPolymorphicNewObjectCaseInsensitive() throws Exception + { + String jsonStr = "{\"child\": { \"@type\": \"mergechilda\", \"name\": \"I'm child A\" }}"; + ObjectMapper mapper = jsonMapperBuilder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_VALUES) + .build(); + + Root root = mapper.readValue(jsonStr, Root.class); + + assertTrue(root.child instanceof MergeChildA); + assertEquals("I'm child A", ((MergeChildA) root.child).name); + } + + public void testPolymorphicNewObjectUnknownTypeId() throws Exception + { + try { + MAPPER.readValue("{\"child\": { \"@type\": \"UnknownChildA\", \"name\": \"I'm child A\" }}", Root.class); + } catch (InvalidTypeIdException e) { + verifyException(e, "Could not resolve type id 'UnknownChildA' as a subtype of"); + } + } + + public void testAliasWithPolymorphic() throws Exception + { + String jsonStr = a2q("{'value': ['ab', {'nm' : 'Bob', 'A' : 17} ] }"); + + PolyWrapperForAlias value = MAPPER.readValue(jsonStr, PolyWrapperForAlias.class); + + assertNotNull(value.value); + AliasBean bean = (AliasBean) value.value; + assertEquals("Bob", bean.name); + assertEquals(17, bean._a); + } + + public void testGetMechanism() + { + final DeserializationConfig config = MAPPER.getDeserializationConfig(); + JavaType javaType = config.constructType(InnerSub4061B.class); + List namedTypes = new ArrayList<>(); + namedTypes.add(new NamedType(InnerSub4061A.class)); + namedTypes.add(new NamedType(InnerSub4061B.class)); + + SimpleNameIdResolver idResolver = SimpleNameIdResolver.construct(config, javaType, namedTypes, false, true); + + assertEquals(JsonTypeInfo.Id.SIMPLE_NAME, idResolver.getMechanism()); + } + + public void testDuplicateNameLastOneWins() throws Exception + { + String jsonStr = a2q("{'@type':'DuplicateSubClass'}"); + + // deser + DuplicateSuperClass bean = MAPPER.readValue(jsonStr, DuplicateSuperClass.class); + assertType(bean, com.fasterxml.jackson.databind.jsontype.DuplicateSubClass.class); + } +} + +@JsonTypeInfo( + use = JsonTypeInfo.Id.SIMPLE_NAME) +@JsonSubTypes({ + @JsonSubTypes.Type(value = BasicSub4061A.class), + @JsonSubTypes.Type(value = BasicSub4061B.class) +}) +class BasicSuper4061 { } + +class BasicSub4061A extends BasicSuper4061 { } + +class BasicSub4061B extends BasicSuper4061 { } + +class MixedSub4061A extends JsonTypeInfoSimpleClassName4061Test.MixedSuper4061 { } + +class MixedSub4061B extends JsonTypeInfoSimpleClassName4061Test.MixedSuper4061 { } + +class MixedMinimalSub4061A extends JsonTypeInfoSimpleClassName4061Test.MixedMinimalSuper4061 { } + +class MixedMinimalSub4061B extends JsonTypeInfoSimpleClassName4061Test.MixedMinimalSuper4061 { } + +class DuplicateSubClass extends JsonTypeInfoSimpleClassName4061Test.DuplicateSuperClass { }