Skip to content

Commit

Permalink
Polymorphic subtype deduction from available fields (#2813)
Browse files Browse the repository at this point in the history
Polymorphic deduction implemented
  • Loading branch information
drekbour authored Aug 21, 2020
1 parent dd2ed05 commit 9a271ef
Show file tree
Hide file tree
Showing 4 changed files with 388 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -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 <i>names</i> 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<String, Integer> fieldBitIndex;
// Bitmap of available fields in each subtype (including its parents)
private final Map<BitSet, String> subtypeFingerprints;

public AsDeductionTypeDeserializer(JavaType bt, TypeIdResolver idRes, JavaType defaultImpl, DeserializationConfig config, Collection<NamedType> 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<BitSet, String> buildFingerprints(DeserializationConfig config, Collection<NamedType> subtypes) {
boolean ignoreCase = config.isEnabled(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES);

int nextField = 0;
Map<BitSet, String> fingerprints = new HashMap<>();

for (NamedType subtype : subtypes) {
JavaType subtyped = config.getTypeFactory().constructType(subtype.getType());
List<BeanPropertyDefinition> 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<BitSet> 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<BitSet> candidates, int bit) {
for (Iterator<BitSet> iter = candidates.iterator(); iter.hasNext(); ) {
if (!iter.next().get(bit)) {
iter.remove();
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<Object> deser = _findDeserializer(ctxt, typeId);
if (_typeIdVisible) { // need to merge id back in JSON input?
if (tb == null) {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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 <null> to drive this.
return new AsExistingPropertyTypeSerializer(idRes, null, _typeProperty);
}

switch (_includeAs) {
case WRAPPER_ARRAY:
return new AsArrayTypeSerializer(idRes, null);
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 9a271ef

Please sign in to comment.