jackson-databind icon indicating copy to clipboard operation
jackson-databind copied to clipboard

JsonTypeInfo.As.EXTERNAL_PROPERTY does not work with record wrappers

Open yamass opened this issue 3 years ago • 2 comments

Describe the bug When I try to use JsonTypeInfo.As.EXTERNAL_PROPERTY inside a record, I get

com.fasterxml.jackson.databind.exc.ValueInstantiationException: Cannot construct instance of `my.company.fastcheck.analyzer.JacksonExternalTypeIdTest$Parent`, problem: Internal error: no creator index for property 'child' (of type com.fasterxml.jackson.databind.deser.impl.FieldProperty)

Note that it works with normal classes. Code examples below.

Version information 2.13.0

To Reproduce

Using a record as wrapping object: (Fails)

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.DatabindContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver;
import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.IOException;

class JacksonExternalTypeIdTest {

	@Test
	void testExternalTypeIdPropertyInsideRecord() throws IOException {
		ObjectMapper objectMapper = new ObjectMapper();
		Parent parent = objectMapper.readValue("""
			{"type": "CHILLED", "child": {}}
		""", Parent.class);
		Assertions.assertTrue(parent.child instanceof ChilledChild);
	}

	public enum ParentType {
		CHILLED,
		AGGRESSIVE
	}

	public static record Parent(
			ParentType type,
			@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "type")
			@JsonTypeIdResolver(ChildBaseByParentTypeResolver.class)
			ChildBase child
	) {}

	public static interface ChildBase {
	}

	public static record AggressiveChild(String someString) implements ChildBase {
	}

	public static record ChilledChild(String someString) implements ChildBase {
	}

	public static class ChildBaseByParentTypeResolver extends TypeIdResolverBase {

		private JavaType superType;

		@Override
		public void init(JavaType baseType) {
			superType = baseType;
		}

		@Override
		public JsonTypeInfo.Id getMechanism() {
			return JsonTypeInfo.Id.NAME;
		}

		@Override
		public JavaType typeFromId(DatabindContext context, String id) {
			Class<?> subType = switch (id) {
				case "CHILLED" -> ChilledChild.class;
				case "AGGRESSIVE" -> AggressiveChild.class;
				default -> throw new IllegalArgumentException();
			};
			return context.constructSpecializedType(superType, subType);
		}

		@Override
		public String idFromValue(Object value) {
			throw new UnsupportedOperationException();
		}

		@Override
		public String idFromValueAndType(Object value, Class<?> suggestedType) {
			throw new UnsupportedOperationException();
		}
	}
}

Using a class as wrapping object: (Passes)

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.DatabindContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver;
import com.fasterxml.jackson.databind.jsontype.impl.TypeIdResolverBase;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.IOException;

class JacksonExternalTypeIdTest {

	@Test
	void testExternalTypeIdPropertyInsideRecord() throws IOException {
		ObjectMapper objectMapper = new ObjectMapper();
		Parent parent = objectMapper.readValue("""
					{"type": "CHILLED", "child": {}}
				""", Parent.class);
		Assertions.assertTrue(parent.child instanceof ChilledChild);
	}

	public enum ParentType {
		CHILLED,
		AGGRESSIVE
	}

	public static final class Parent {
		private final ParentType type;

		@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "type")
		@JsonTypeIdResolver(ChildBaseByParentTypeResolver.class)
		private final ChildBase child;

		public Parent(
				@JsonProperty("type") ParentType type,
				@JsonProperty("child") ChildBase child
		) {
			this.type = type;
			this.child = child;
		}

		public ParentType type() {
			return type;
		}

		public ChildBase child() {
			return child;
		}

	}

	public static interface ChildBase {
	}

	public static record AggressiveChild(String someString) implements ChildBase {
	}

	public static record ChilledChild(String someString) implements ChildBase {
	}

	public static class ChildBaseByParentTypeResolver extends TypeIdResolverBase {

		private JavaType superType;

		@Override
		public void init(JavaType baseType) {
			superType = baseType;
		}

		@Override
		public JsonTypeInfo.Id getMechanism() {
			return JsonTypeInfo.Id.NAME;
		}

		@Override
		public JavaType typeFromId(DatabindContext context, String id) {
			Class<?> subType = switch (id) {
				case "CHILLED" -> ChilledChild.class;
				case "AGGRESSIVE" -> AggressiveChild.class;
				default -> throw new IllegalArgumentException();
			};
			return context.constructSpecializedType(superType, subType);
		}

		@Override
		public String idFromValue(Object value) {
			throw new UnsupportedOperationException();
		}

		@Override
		public String idFromValueAndType(Object value, Class<?> suggestedType) {
			throw new UnsupportedOperationException();
		}
	}
}

Expected behavior Should work with records, too. For now, using normal class as workaround.

Additional context (none)

yamass avatar Dec 07 '21 10:12 yamass

Has there been any investigation into this? It's a bit annoying to have to use one class file when all of the rest of my stuff is using records.

I'm doing this a bit differently (using the JsonTypeInfo and JsonSubTypes annotations) so here is a test case demonstrating how using records fails while using a class works:

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.IOException;

public class JacksonRecordTypeInfoTest {


    // This test works since it's using the ParentClass class type
    @Test
    void testJsonSubTypesUsingClass() throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        ParentClass parent = objectMapper.readValue("""
			{"type": "CHILLED", "child": {}}
		""", ParentClass.class);
        Assertions.assertTrue(parent.child instanceof ChilledChild);
    }

    // This test fails since it uses the ParentRecord record type
    @Test
    void testJsonSubTypesUsingRecord() throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        ParentRecord parent = objectMapper.readValue("""
			{"type": "CHILLED", "child": {}}
		""", ParentRecord.class);
        Assertions.assertTrue(parent.child instanceof ChilledChild);
    }

    public enum ParentType {
        CHILLED,
        AGGRESSIVE
    }

    public static class ParentClass
    {
        ParentType type;
        @JsonTypeInfo(
                use = JsonTypeInfo.Id.NAME,
                include = JsonTypeInfo.As.EXTERNAL_PROPERTY,
                property = "type"
        )
        @JsonSubTypes({
                @JsonSubTypes.Type(value = AggressiveChild.class, name = "AGGRESSIVE"),
                @JsonSubTypes.Type(value = ChilledChild.class, name = "CHILLED")
        })
        ChildBase child;
    }

    public record ParentRecord(
        ParentType type,
        @JsonTypeInfo(
                use = JsonTypeInfo.Id.NAME,
                include = JsonTypeInfo.As.EXTERNAL_PROPERTY,
                property = "type"
        )
        @JsonSubTypes({
                @JsonSubTypes.Type(value = AggressiveChild.class, name = "AGGRESSIVE"),
                @JsonSubTypes.Type(value = ChilledChild.class, name = "CHILLED")
        })
        ChildBase child
    ){}

    public interface ChildBase {
    }

    public record AggressiveChild(String someString) implements ChildBase {
    }

    public record ChilledChild(String someString) implements ChildBase {
    }
}

StFS avatar Apr 12 '22 18:04 StFS

I have not had any time to work on this unfortunately. I am not sure if this is due to general issues with Records (related to introspection of Constructors wrt other properties), or specifically because Records cannot be made to work with EXTERNAL_PROPERTY. The reason it might be latter is that due to immutability, the way this feature usually works may well not be available -- since passing external property type ids via Creator methods itself is not supported (and for Records they are the only mechanism).

I have been hoping to rewrite property introspection for Jackson 2.14, and if I ever find time to do that, it would help unravel a set of record-related issues, including this one.

cowtowncoder avatar Apr 19 '22 17:04 cowtowncoder