diff --git a/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java new file mode 100644 index 0000000000..1f10df4617 --- /dev/null +++ b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/AsDeductionTypeDeserializer.java @@ -0,0 +1,152 @@ +package com.fasterxml.jackson.databind.jsontype.impl; + +import java.io.IOException; +import java.util.BitSet; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; +import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; +import com.fasterxml.jackson.databind.util.TokenBuffer; + +/** + * A {@link TypeDeserializer} capable of deducing polymorphic types based on the fields available. Deduction + * is limited to the names of child fields (not their values or, consequently, any nested descendants). + * Exceptions will be thrown if not enough unique information is present to select a single subtype. + */ +public class AsDeductionTypeDeserializer extends AsPropertyTypeDeserializer { + + // Fieldname -> bitmap-index of every field discovered, across all subtypes + private final Map fieldBitIndex; + // Bitmap of available fields in each subtype (including its parents) + private final Map subtypeFingerprints; + + public AsDeductionTypeDeserializer(JavaType bt, TypeIdResolver idRes, JavaType defaultImpl, DeserializationConfig config, Collection subtypes) { + super(bt, idRes, null, false, defaultImpl); + fieldBitIndex = new HashMap<>(); + subtypeFingerprints = buildFingerprints(config, subtypes); + } + + public AsDeductionTypeDeserializer(AsDeductionTypeDeserializer src, BeanProperty property) { + super(src, property); + fieldBitIndex = src.fieldBitIndex; + subtypeFingerprints = src.subtypeFingerprints; + } + + @Override + public JsonTypeInfo.As getTypeInclusion() { + return null; + } + + @Override + public TypeDeserializer forProperty(BeanProperty prop) { + return (prop == _property) ? this : new AsDeductionTypeDeserializer(this, prop); + } + + protected Map buildFingerprints(DeserializationConfig config, Collection subtypes) { + boolean ignoreCase = config.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES); + + int nextField = 0; + Map fingerprints = new HashMap<>(); + + for (NamedType subtype : subtypes) { + JavaType subtyped = config.getTypeFactory().constructType(subtype.getType()); + List properties = config.introspect(subtyped).findProperties(); + + BitSet fingerprint = new BitSet(nextField + properties.size()); + for (BeanPropertyDefinition property : properties) { + String name = property.getName(); + if (ignoreCase) name = name.toLowerCase(); + Integer bitIndex = fieldBitIndex.get(name); + if (bitIndex == null) { + bitIndex = nextField; + fieldBitIndex.put(name, nextField++); + } + fingerprint.set(bitIndex); + } + + String existingFingerprint = fingerprints.put(fingerprint, subtype.getType().getName()); + + // Validate uniqueness + if (existingFingerprint != null) { + throw new IllegalStateException( + String.format("Subtypes %s and %s have the same signature and cannot be uniquely deduced.", existingFingerprint, subtype.getType().getName()) + ); + } + + } + return fingerprints; + } + + @Override + public Object deserializeTypedFromObject(JsonParser p, DeserializationContext ctxt) throws IOException { + + JsonToken t = p.currentToken(); + if (t == JsonToken.START_OBJECT) { + t = p.nextToken(); + } else { + /* This is most likely due to the fact that not all Java types are + * serialized as JSON Objects; so if "as-property" inclusion is requested, + * serialization of things like Lists must be instead handled as if + * "as-wrapper-array" was requested. + * But this can also be due to some custom handling: so, if "defaultImpl" + * is defined, it will be asked to handle this case. + */ + return _deserializeTypedUsingDefaultImpl(p, ctxt, null); + } + + List candidates = new LinkedList<>(subtypeFingerprints.keySet()); + + // Record processed tokens as we must rewind once after deducing the deserializer to use + TokenBuffer tb = new TokenBuffer(p, ctxt); + boolean ignoreCase = ctxt.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES); + + for (; t == JsonToken.FIELD_NAME; t = p.nextToken()) { + String name = p.getCurrentName(); + if (ignoreCase) name = name.toLowerCase(); + + tb.copyCurrentStructure(p); + + Integer bit = fieldBitIndex.get(name); + if (bit != null) { + // field is known by at least one subtype + prune(candidates, bit); + if (candidates.size() == 1) { + return _deserializeTypedForId(p, ctxt, tb, subtypeFingerprints.get(candidates.get(0))); + } + } + } + + throw new InvalidTypeIdException( + p, + String.format("Cannot deduce unique subtype of %s (%d candidates match)", _baseType.toString(), candidates.size()), + _baseType + , "DEDUCED" + ); + } + + // Keep only fingerprints containing this field + private static void prune(List candidates, int bit) { + for (Iterator iter = candidates.iterator(); iter.hasNext(); ) { + if (!iter.next().get(bit)) { + iter.remove(); + } + } + } + +} diff --git a/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/AsPropertyTypeDeserializer.java b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/AsPropertyTypeDeserializer.java index cd5ef251c3..939dc146f8 100644 --- a/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/AsPropertyTypeDeserializer.java +++ b/src/main/java/com/fasterxml/jackson/databind/jsontype/impl/AsPropertyTypeDeserializer.java @@ -3,9 +3,15 @@ import java.io.IOException; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; -import com.fasterxml.jackson.core.*; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.core.util.JsonParserSequence; -import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; import com.fasterxml.jackson.databind.util.TokenBuffer; @@ -96,7 +102,7 @@ public Object deserializeTypedFromObject(JsonParser p, DeserializationContext ct p.nextToken(); // to point to the value if (name.equals(_typePropertyName) || (ignoreCase && name.equalsIgnoreCase(_typePropertyName))) { // gotcha! - return _deserializeTypedForId(p, ctxt, tb); + return _deserializeTypedForId(p, ctxt, tb, p.getText()); } if (tb == null) { tb = new TokenBuffer(p, ctxt); @@ -109,9 +115,7 @@ public Object deserializeTypedFromObject(JsonParser p, DeserializationContext ct @SuppressWarnings("resource") protected Object _deserializeTypedForId(JsonParser p, DeserializationContext ctxt, - TokenBuffer tb) throws IOException - { - String typeId = p.getText(); + TokenBuffer tb, String typeId) throws IOException { JsonDeserializer deser = _findDeserializer(ctxt, typeId); if (_typeIdVisible) { // need to merge id back in JSON input? if (tb == null) { @@ -131,7 +135,7 @@ protected Object _deserializeTypedForId(JsonParser p, DeserializationContext ctx // deserializer should take care of closing END_OBJECT as well return deser.deserialize(p, ctxt); } - + // off-lined to keep main method lean and mean... @SuppressWarnings("resource") protected Object _deserializeTypedUsingDefaultImpl(JsonParser p, 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 acba22f926..594843f8ce 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 @@ -3,12 +3,19 @@ import java.util.Collection; import com.fasterxml.jackson.annotation.JsonTypeInfo; - -import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.DeserializationConfig; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationConfig; import com.fasterxml.jackson.databind.annotation.NoClass; import com.fasterxml.jackson.databind.cfg.MapperConfig; -import com.fasterxml.jackson.databind.jsontype.*; +import com.fasterxml.jackson.databind.jsontype.NamedType; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator.Validity; +import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; +import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; +import com.fasterxml.jackson.databind.jsontype.TypeResolverBuilder; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; import com.fasterxml.jackson.databind.util.ClassUtil; /** @@ -89,8 +96,15 @@ public TypeSerializer buildTypeSerializer(SerializationConfig config, return null; } } + TypeIdResolver idRes = idResolver(config, baseType, subTypeValidator(config), subtypes, true, false); + + if(_idType == JsonTypeInfo.Id.DEDUCTION) { + // Deduction doesn't require a type property. We use EXISTING_PROPERTY with a name of to drive this. + return new AsExistingPropertyTypeSerializer(idRes, null, _typeProperty); + } + switch (_includeAs) { case WRAPPER_ARRAY: return new AsArrayTypeSerializer(idRes, null); @@ -135,6 +149,11 @@ public TypeDeserializer buildTypeDeserializer(DeserializationConfig config, JavaType defaultImpl = defineDefaultImpl(config, baseType); + if(_idType == JsonTypeInfo.Id.DEDUCTION) { + // Deduction doesn't require an includeAs property + return new AsDeductionTypeDeserializer(baseType, idRes, defaultImpl, config, subtypes); + } + // First, method for converting type info to type id: switch (_includeAs) { case WRAPPER_ARRAY: @@ -268,6 +287,7 @@ protected TypeIdResolver idResolver(MapperConfig config, if (_customIdResolver != null) { return _customIdResolver; } if (_idType == null) throw new IllegalStateException("Cannot build, 'init()' not yet called"); switch (_idType) { + case DEDUCTION: // Deduction produces class names to be resolved case CLASS: return ClassNameIdResolver.construct(baseType, config, subtypeValidator); case MINIMAL_CLASS: diff --git a/src/test/java/com/fasterxml/jackson/databind/jsontype/TestPolymorphicDeduction.java b/src/test/java/com/fasterxml/jackson/databind/jsontype/TestPolymorphicDeduction.java new file mode 100644 index 0000000000..fb47498e2c --- /dev/null +++ b/src/test/java/com/fasterxml/jackson/databind/jsontype/TestPolymorphicDeduction.java @@ -0,0 +1,202 @@ +package com.fasterxml.jackson.databind.jsontype; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.BaseMapTest; +import com.fasterxml.jackson.databind.DeserializationFeature; +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.type.TypeFactory; +import static com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import static com.fasterxml.jackson.annotation.JsonTypeInfo.Id.DEDUCTION; + +public class TestPolymorphicDeduction extends BaseMapTest { + + @JsonTypeInfo(use = DEDUCTION) + @JsonSubTypes( {@Type(LiveCat.class), @Type(DeadCat.class)}) + static abstract class Cat { + public final String name; + + protected Cat(String name) { + this.name = name; + } + } + + static class DeadCat extends Cat { + public String causeOfDeath; + + DeadCat(@JsonProperty("name") String name) { + super(name); + } + } + + static class LiveCat extends Cat { + public boolean angry; + + LiveCat(@JsonProperty("name") String name) { + super(name); + } + } + + static class Box { + public Cat cat; + } + + /* + /********************************************************** + /* Mock data + /********************************************************** + */ + + private static final String deadCatJson = aposToQuotes("{'name':'Felix','causeOfDeath':'entropy'}"); + private static final String liveCatJson = aposToQuotes("{'name':'Felix','angry':true}"); + private static final String luckyCatJson = aposToQuotes("{'name':'Felix','angry':true,'lives':8}"); + private static final String ambiguousCatJson = aposToQuotes("{'name':'Felix','age':2}"); + private static final String box1Json = aposToQuotes("{'cat':" + liveCatJson + "}"); + private static final String box2Json = aposToQuotes("{'cat':" + deadCatJson + "}"); + private static final String arrayOfCatsJson = aposToQuotes("[" + liveCatJson + "," + deadCatJson + "]"); + private static final String mapOfCatsJson = aposToQuotes("{'live':" + liveCatJson + "}"); + + /* + /********************************************************** + /* Test methods + /********************************************************** + */ + + public void testSimpleInference() throws Exception { + Cat cat = sharedMapper().readValue(liveCatJson, Cat.class); + assertTrue(cat instanceof LiveCat); + assertSame(cat.getClass(), LiveCat.class); + assertEquals("Felix", cat.name); + assertTrue(((LiveCat)cat).angry); + + cat = sharedMapper().readValue(deadCatJson, Cat.class); + assertTrue(cat instanceof DeadCat); + assertSame(cat.getClass(), DeadCat.class); + assertEquals("Felix", cat.name); + assertEquals("entropy", ((DeadCat)cat).causeOfDeath); + } + + public void testCaseInsensitiveInference() throws Exception { + Cat cat = newJsonMapper() // Don't use shared mapper! + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true) + .readValue(deadCatJson.toUpperCase(), Cat.class); + assertTrue(cat instanceof DeadCat); + assertSame(cat.getClass(), DeadCat.class); + assertEquals("FELIX", cat.name); + assertEquals("ENTROPY", ((DeadCat)cat).causeOfDeath); + } + + // TODO not currently supported +// public void testCaseInsensitivePerFieldInference() throws Exception { +// ObjectMapper mapper = newJsonMapper(); // Don't use shared mapper! +// mapper.configOverride(DeadCat.class) +// .setFormat(JsonFormat.Value.empty() +// .withFeature(JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES)); +// Cat cat = mapper.readValue(deadCatJson.replace("causeOfDeath", "CAUSEOFDEATH"), Cat.class); +// assertTrue(cat instanceof DeadCat); +// assertSame(cat.getClass(), DeadCat.class); +// assertEquals("Felix", cat.name); +// assertEquals("Entropy", ((DeadCat)cat).causeOfDeath); +// } + + public void testContainedInference() throws Exception { + Box box = sharedMapper().readValue(box1Json, Box.class); + assertTrue(box.cat instanceof LiveCat); + assertSame(box.cat.getClass(), LiveCat.class); + assertEquals("Felix", box.cat.name); + assertTrue(((LiveCat)box.cat).angry); + + box = sharedMapper().readValue(box2Json, Box.class); + assertTrue(box.cat instanceof DeadCat); + assertSame(box.cat.getClass(), DeadCat.class); + assertEquals("Felix", box.cat.name); + assertEquals("entropy", ((DeadCat)box.cat).causeOfDeath); + } + + public void testListInference() throws Exception { + JavaType listOfCats = TypeFactory.defaultInstance().constructParametricType(List.class, Cat.class); + List boxes = sharedMapper().readValue(arrayOfCatsJson, listOfCats); + assertTrue(boxes.get(0) instanceof LiveCat); + assertTrue(boxes.get(1) instanceof DeadCat); + } + + public void testMapInference() throws Exception { + JavaType mapOfCats = TypeFactory.defaultInstance().constructParametricType(Map.class, String.class, Cat.class); + Map map = sharedMapper().readValue(mapOfCatsJson, mapOfCats); + assertEquals(1, map.size()); + assertTrue(map.entrySet().iterator().next().getValue() instanceof LiveCat); + } + + public void testArrayInference() throws Exception { + Cat[] boxes = sharedMapper().readValue(arrayOfCatsJson, Cat[].class); + assertTrue(boxes[0] instanceof LiveCat); + assertTrue(boxes[1] instanceof DeadCat); + } + + public void testIgnoreProperties() throws Exception { + Cat cat = sharedMapper().reader() + .without(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(luckyCatJson, Cat.class); + assertTrue(cat instanceof LiveCat); + assertSame(cat.getClass(), LiveCat.class); + assertEquals("Felix", cat.name); + assertTrue(((LiveCat)cat).angry); + } + + static class AnotherLiveCat extends Cat { + public boolean angry; + + AnotherLiveCat(@JsonProperty("name") String name) { + super(name); + } + } + + public void testAmbiguousClasses() throws Exception { + try { + ObjectMapper mapper = newJsonMapper(); // Don't use shared mapper! + mapper.registerSubtypes(AnotherLiveCat.class); + Cat cat = mapper.readValue(liveCatJson, Cat.class); + fail("Should not get here"); + } catch (IllegalStateException e) { + // NO OP + } + } + + public void testAmbiguousProperties() throws Exception { + try { + Cat cat = sharedMapper().readValue(ambiguousCatJson, Cat.class); + fail("Should not get here"); + } catch (InvalidTypeIdException e) { + // NO OP + } + } + + public void testSimpleSerialization() throws Exception { + // Given: + JavaType listOfCats = TypeFactory.defaultInstance().constructParametricType(List.class, Cat.class); + List list = sharedMapper().readValue(arrayOfCatsJson, listOfCats); + Cat cat = list.get(0); + // When: + String json = sharedMapper().writeValueAsString(list.get(0)); + // Then: + assertEquals(liveCatJson, json); + } + + public void testListSerialization() throws Exception { + // Given: + JavaType listOfCats = TypeFactory.defaultInstance().constructParametricType(List.class, Cat.class); + List list = sharedMapper().readValue(arrayOfCatsJson, listOfCats); + // When: + String json = sharedMapper().writeValueAsString(list); + // Then: + assertEquals(arrayOfCatsJson, json); + } + +}