diff --git a/README.md b/README.md index 005309e..8527513 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Java port of the `SealedPolymorphismSupport` added to jackson-module-scala in using the same `@type` property and the same name-derivation rules. Enums are the one place the two diverge: Scala tags a `case object`, whereas this module leaves a Java enum to Jackson as a string. -> **Status: early.** Covered by 76 tests, including ports of the Scala module's +> **Status: early.** Covered by 90 tests, including ports of the Scala module's > `SealedPolymorphismSpec` and `NestedPolymorphismSpec`, so the examples below are verified output. > Snapshots are published, but there is no release yet and the API may still change. @@ -74,8 +74,45 @@ mapper.readValue("{\"@type\":\"Dog\",\"name\":\"rex\"}", Animal.class); // Dog[name=rex] ``` -The module only ever looks at types carrying the marker, so registering it has no effect on -anything else your application serializes. +The module only ever looks at types that have opted in — through the marker, or through a +[mix-in](#hierarchies-you-cannot-change) — so adding it has no effect on anything else your +application serializes. + +## Hierarchies you cannot change + +Extending the marker means editing the base type. Where that is not possible — a hierarchy from a +library, or generated code — register a Jackson **mix-in** that extends the marker instead: + +```java +// yours, in your own package +public interface AnimalMixIn extends SealedPolymorphismSupport {} + +ObjectMapper mapper = JsonMapper.builder() + .addModule(new SealedPolymorphismModule()) + .addMixIn(Animal.class, AnimalMixIn.class) + .build(); +``` + +The mix-in carries the marker and nothing else. It does not — and cannot — extend the hierarchy it +is mixed into: a sealed type's `permits` clause names its subtypes, and someone who cannot change +those classes cannot add themselves to it. Jackson never requires a mix-in to be a subtype of what +it is mixed into; it only harvests annotations and supertypes from it. + +Mix it into the **root**; its implementations follow from the `permits` clause. A mix-in does not +change the type on the JVM, so the marker is not inherited by the implementations the way it would +be if the base extended it — only the root is opted in, and the module reads that from the mapper's +configuration rather than from the classes. + +A mixed-in hierarchy is handled exactly as a marked one, and held to the same requirements: it must +be sealed, a `non-sealed` member is still reported, and enums are still left to Jackson. Mixing into +a type part way down a hierarchy is allowed and makes that type the root. + +Two mix-ins that are not opt-ins: + +| Mix-in | Result | +| --- | --- | +| does not extend `SealedPolymorphismSupport` | opts nothing in — the hierarchy stays untouched | +| carries `@JsonTypeInfo` as well as the marker | the module stands down, as it does for the annotation on the class | ## How names are derived @@ -208,12 +245,13 @@ performance but not behaviour. ## Tests -76 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`: +90 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`: | Test | Covers | | --- | --- | | `poly/SealedPolymorphismTest` | Ported from the Scala `SealedPolymorphismSpec` | | `poly/EnumsUntouchedTest` | That enums serialize identically with and without this module | +| `poly/MixInTest` | Hierarchies opted in by a Jackson mix-in rather than by the marker | | `poly/NestedPolymorphismTest` | Ported from the Scala `NestedPolymorphismSpec` — a polymorphic value holding a polymorphic value | | `poly/InvalidHierarchyTest` | The three ways a hierarchy can fail to be closed — not sealed, reopened by a `non-sealed` member, clashing derived names — on both the read and the write path, plus that a marked enum is ignored | | `SealedTypesTest` | The name derivation itself, and resolution | diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphicDeserializer.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphicDeserializer.java index b593601..bb24d42 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphicDeserializer.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphicDeserializer.java @@ -14,23 +14,25 @@ final class SealedPolymorphicDeserializer extends StdDeserializer { private static final long serialVersionUID = 1L; private final Class baseClass; + private final SealedHierarchy hierarchy; - SealedPolymorphicDeserializer(Class baseClass) { + SealedPolymorphicDeserializer(Class baseClass, SealedHierarchy hierarchy) { super(baseClass); this.baseClass = baseClass; + this.hierarchy = hierarchy; } @Override public Object deserialize(JsonParser p, DeserializationContext ctxt) { if (p.currentToken() != JsonToken.START_OBJECT) { - String hint = SealedTypes.hierarchyOf(baseClass).enumMemberHint(); + String hint = hierarchy.enumMemberHint(); return ctxt.reportInputMismatch(baseClass, "Expected a JSON object with a %s property to create %s.%s", SealedTypes.TYPE_PROPERTY_NAME, baseClass.getName(), hint == null ? "" : hint); } TaggedObject tagged = TaggedObject.split(p, ctxt); - Class subtype = SealedTypes.hierarchyOf(baseClass).resolve(baseClass, tagged.typeName()); + Class subtype = hierarchy.resolve(baseClass, tagged.typeName()); if (subtype == null) { - return TaggedObject.unresolved(ctxt, baseClass, tagged.typeName()); + return TaggedObject.unresolved(ctxt, baseClass, tagged.typeName(), hierarchy); } return ctxt.readValue(tagged.parser(), subtype); } diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializerModifier.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializerModifier.java index d3e7789..be8015a 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializerModifier.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializerModifier.java @@ -19,11 +19,11 @@ final class SealedPolymorphismDeserializerModifier extends ValueDeserializerModi public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanDescription.Supplier beanDescRef, BeanDeserializerBuilder builder) { Class rawClass = beanDescRef.getBeanClass(); - if (!SealedTypes.isMarked(rawClass)) { + if (!SealedTypes.isMarked(config, rawClass)) { return builder; } - SealedTypes.checkNoConflictingJsonTypeInfo(rawClass); - if (SealedTypes.isSupported(rawClass) && SealedTypes.isConcrete(rawClass)) { + SealedTypes.checkNoConflictingJsonTypeInfo(config, rawClass); + if (SealedTypes.isSupported(config, rawClass) && SealedTypes.isConcrete(rawClass)) { builder.addIgnorable(SealedTypes.TYPE_PROPERTY_NAME); } return builder; @@ -33,12 +33,12 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD public ValueDeserializer modifyDeserializer(DeserializationConfig config, BeanDescription.Supplier beanDescRef, ValueDeserializer deserializer) { Class rawClass = beanDescRef.getBeanClass(); - if (!SealedTypes.needsSubtypeDispatch(rawClass)) { + if (!SealedTypes.needsSubtypeDispatch(config, rawClass)) { return deserializer; } @SuppressWarnings("unchecked") ValueDeserializer delegate = (ValueDeserializer) deserializer; - return new TaggedBeanDeserializer(rawClass, delegate); + return new TaggedBeanDeserializer(rawClass, SealedTypes.hierarchyOf(config, rawClass), delegate); } } diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializers.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializers.java index c1370f8..4bfa8b3 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializers.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializers.java @@ -13,11 +13,13 @@ final class SealedPolymorphismDeserializers extends Deserializers.Base { public ValueDeserializer findBeanDeserializer(JavaType type, DeserializationConfig config, BeanDescription.Supplier beanDescRef) { Class rawClass = type.getRawClass(); - return hasDeserializerFor(config, rawClass) ? new SealedPolymorphicDeserializer(rawClass) : null; + return hasDeserializerFor(config, rawClass) + ? new SealedPolymorphicDeserializer(rawClass, SealedTypes.hierarchyOf(config, rawClass)) + : null; } @Override public boolean hasDeserializerFor(DeserializationConfig config, Class valueType) { - return SealedTypes.isBaseType(valueType); + return SealedTypes.isBaseType(config, valueType); } } diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSerializerModifier.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSerializerModifier.java index 4adaa4c..f8c1cac 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSerializerModifier.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSerializerModifier.java @@ -17,17 +17,17 @@ final class SealedPolymorphismSerializerModifier extends ValueSerializerModifier public ValueSerializer modifySerializer(SerializationConfig config, BeanDescription.Supplier beanDescRef, ValueSerializer serializer) { Class rawClass = beanDescRef.getBeanClass(); - if (!SealedTypes.isMarked(rawClass)) { + if (!SealedTypes.isMarked(config, rawClass)) { return serializer; } - SealedTypes.checkNoConflictingJsonTypeInfo(rawClass); + SealedTypes.checkNoConflictingJsonTypeInfo(config, rawClass); // a base type is never written directly - only the implementation dispatched to at runtime - if (!SealedTypes.isSupported(rawClass) || !SealedTypes.isConcrete(rawClass)) { + if (!SealedTypes.isSupported(config, rawClass) || !SealedTypes.isConcrete(rawClass)) { return serializer; } @SuppressWarnings("unchecked") ValueSerializer delegate = (ValueSerializer) serializer; - return new TypeTaggedSerializer(SealedTypes.hierarchyOf(rawClass).nameOf(rawClass), delegate); + return new TypeTaggedSerializer(SealedTypes.hierarchyOf(config, rawClass).nameOf(rawClass), delegate); } } diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java index 84f6581..fa03d65 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java @@ -46,6 +46,31 @@ * out as JSON that could not be read back. Records are closed by construction and need no modifier * of their own. * + *

Hierarchies you cannot change

+ * + *

Extending this interface means editing the base type. Where that is not possible - a hierarchy + * from a library, or generated code - register a Jackson mix-in that extends it instead: + * + *

{@code
+ * public interface AnimalMixIn extends SealedPolymorphismSupport {}
+ *
+ * JsonMapper.builder()
+ *         .addModule(new SealedPolymorphismModule())
+ *         .addMixIn(Animal.class, AnimalMixIn.class)
+ *         .build();
+ * }
+ * + *

The mix-in carries the marker and nothing else. It does not - and cannot - extend the hierarchy + * it is mixed into: a sealed type's {@code permits} clause names its subtypes, and someone who + * cannot change those classes cannot add themselves to it. Jackson never requires a mix-in to be a + * subtype of what it is mixed into. + * + *

Mix it into the root; its implementations follow from the {@code permits} clause. A + * mix-in does not change the type on the JVM, so the marker is not inherited by the implementations + * the way it would be if the base extended it - only the root is opted in, and the module reads that + * from the mapper's configuration. A hierarchy opted in this way is held to exactly the same + * requirements as one that extends the marker directly. + * *

Enums are left to Jackson

* *

An enum permitted by the root is not handled here. Jackson writes an enum as a string and keeps diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java index 0a22178..6a6fcca 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java @@ -2,11 +2,13 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import tools.jackson.databind.introspect.MixInResolver; /** * Naming rules and policy behind {@link SealedPolymorphismSupport}. @@ -29,7 +31,49 @@ private SealedTypes() { * marker interface can be referred to without being treated as a hierarchy. */ static boolean isMarked(Class clazz) { - return clazz != null && clazz != MARKER && MARKER.isAssignableFrom(clazz); + return isMarked(null, clazz); + } + + /** + * True for a type in a hierarchy this module handles. + * + *

A hierarchy opts in by extending the marker, or - where its source cannot be changed - by + * having a mix-in that extends the marker registered for its root. A mix-in does not change the + * type on the JVM, so the marker is not inherited by the implementations the way it would be: + * only the root is opted in directly, and its implementations are reached from there. That is + * why this asks the mapper's configuration rather than the class alone. + */ + static boolean isMarked(MixInResolver mixIns, Class clazz) { + if (clazz == null || clazz == MARKER) { + return false; + } + // unchanged cost for a mapper with no mix-ins, which is the usual case + if (MARKER.isAssignableFrom(clazz)) { + return true; + } + return hasMixIns(mixIns) && findRoot(mixIns, clazz) != null; + } + + /** True where the mix-in registered for a type carries the marker. */ + private static boolean markedByMixIn(MixInResolver mixIns, Class clazz) { + if (!hasMixIns(mixIns)) { + return false; + } + Class mixIn = mixIns.findMixInClassFor(clazz); + return mixIn != null && MARKER.isAssignableFrom(mixIn); + } + + private static boolean hasMixIns(MixInResolver mixIns) { + return mixIns != null && mixIns.hasMixIns(); + } + + /** True where the mix-in registered for a type supplies {@code @JsonTypeInfo}. */ + private static boolean jsonTypeInfoByMixIn(MixInResolver mixIns, Class clazz) { + if (!hasMixIns(mixIns)) { + return false; + } + Class mixIn = mixIns.findMixInClassFor(clazz); + return mixIn != null && mixIn.getAnnotation(JsonTypeInfo.class) != null; } /** @@ -38,8 +82,19 @@ static boolean isMarked(Class clazz) { * twice. */ static boolean isSupported(Class clazz) { + return isSupported(null, clazz); + } + + static boolean isSupported(MixInResolver mixIns, Class clazz) { // an enum is Jackson's to write, as a string; this module does not take that over - return isMarked(clazz) && enumClassOf(clazz) == null && !hierarchyOf(clazz).isJacksonOwned(); + if (!isMarked(mixIns, clazz) || enumClassOf(clazz) != null) { + return false; + } + // asked before the hierarchy is built, since a hierarchy Jackson owns need not be sealed + if (jsonTypeInfoByMixIn(mixIns, rootOf(mixIns, clazz))) { + return false; + } + return !hierarchyOf(mixIns, clazz).isJacksonOwned(); } /** True for a type that can hold a value of its own, so can carry a name of its own. */ @@ -52,7 +107,11 @@ static boolean isConcrete(Class clazz) { * class. Reading one means reading whatever its {@code @type} names. */ static boolean isBaseType(Class clazz) { - return isSupported(clazz) && !isConcrete(clazz); + return isBaseType(null, clazz); + } + + static boolean isBaseType(MixInResolver mixIns, Class clazz) { + return isSupported(mixIns, clazz) && !isConcrete(clazz); } /** @@ -62,7 +121,11 @@ static boolean isBaseType(Class clazz) { * would silently be read back as the type the property was declared as. */ static boolean needsSubtypeDispatch(Class clazz) { - return isSupported(clazz) && isConcrete(clazz) && clazz.isSealed(); + return needsSubtypeDispatch(null, clazz); + } + + static boolean needsSubtypeDispatch(MixInResolver mixIns, Class clazz) { + return isSupported(mixIns, clazz) && isConcrete(clazz) && clazz.isSealed(); } /** @@ -73,8 +136,13 @@ static boolean needsSubtypeDispatch(Class clazz) { * back. */ static void checkNoConflictingJsonTypeInfo(Class clazz) { - if (isSupported(clazz) && clazz.getAnnotation(JsonTypeInfo.class) != null) { - Class root = hierarchyOf(clazz).root(); + checkNoConflictingJsonTypeInfo(null, clazz); + } + + static void checkNoConflictingJsonTypeInfo(MixInResolver mixIns, Class clazz) { + if (isSupported(mixIns, clazz) + && (clazz.getAnnotation(JsonTypeInfo.class) != null || jsonTypeInfoByMixIn(mixIns, clazz))) { + Class root = hierarchyOf(mixIns, clazz).root(); throw new IllegalArgumentException(clazz.getName() + " carries @JsonTypeInfo but belongs to the " + MARKER.getSimpleName() + " hierarchy rooted at " + root.getName() + ". Move the annotation to " + root.getSimpleName() + " to use Jackson's polymorphic handling for the whole hierarchy, or " @@ -143,19 +211,33 @@ static Class enumClassOf(Class clazz) { * {@code @type}. */ static Class rootOf(Class clazz) { - Set> marked = new LinkedHashSet<>(); - collectMarked(clazz, marked); + return rootOf(null, clazz); + } + + static Class rootOf(MixInResolver mixIns, Class clazz) { + Class root = findRoot(mixIns, clazz); + if (root == null) { + throw new IllegalArgumentException(clazz.getName() + " does not implement " + MARKER.getName() + + ", and no mix-in carrying it is registered for anything above it."); + } + return root; + } + + /** The top of the opted-in hierarchy {@code clazz} belongs to, or {@code null} if there is none. */ + private static Class findRoot(MixInResolver mixIns, Class clazz) { + Set> optedIn = new LinkedHashSet<>(); + collectOptedIn(mixIns, clazz, optedIn, new HashSet<>()); + if (optedIn.isEmpty()) { + return null; + } Class root = null; - for (Class candidate : marked) { + for (Class candidate : optedIn) { if (root == null || candidate.isAssignableFrom(root)) { root = candidate; } } - if (root == null) { - throw new IllegalArgumentException(clazz.getName() + " does not implement " + MARKER.getName() + "."); - } List disjoint = new ArrayList<>(); - for (Class candidate : marked) { + for (Class candidate : optedIn) { if (!root.isAssignableFrom(candidate)) { disjoint.add(candidate.getName()); } @@ -163,28 +245,39 @@ static Class rootOf(Class clazz) { if (!disjoint.isEmpty()) { throw new IllegalArgumentException(clazz.getName() + " belongs to more than one " + MARKER.getSimpleName() + " hierarchy - " + root.getName() + " and " + String.join(", ", disjoint) - + " - so there is no single hierarchy whose names it could be written under. Mark only one of " - + "them, and let the other be a plain interface."); + + " - so there is no single hierarchy whose names it could be written under. Opt only one of " + + "them in, and let the other be a plain interface."); } return root; } - private static void collectMarked(Class clazz, Set> into) { - if (clazz == null || clazz == MARKER || !MARKER.isAssignableFrom(clazz)) { + /** + * Collects every supertype that has opted in. A mix-in marks one type rather than everything + * below it, so unlike the marker the walk cannot stop at a supertype that has not opted in - a + * mix-in marked root may sit above several plain classes. + */ + private static void collectOptedIn(MixInResolver mixIns, Class clazz, Set> into, + Set> visited) { + if (clazz == null || clazz == Object.class || clazz == MARKER || !visited.add(clazz)) { return; } - if (!into.add(clazz)) { - return; + if (MARKER.isAssignableFrom(clazz) || markedByMixIn(mixIns, clazz)) { + into.add(clazz); } - collectMarked(clazz.getSuperclass(), into); + collectOptedIn(mixIns, clazz.getSuperclass(), into, visited); for (Class iface : clazz.getInterfaces()) { - collectMarked(iface, into); + collectOptedIn(mixIns, iface, into, visited); } } + /** The resolved hierarchy {@code clazz} belongs to, built once per root. */ static SealedHierarchy hierarchyOf(Class clazz) { - return cache.byMember.get(clazz); + return hierarchyOf(null, clazz); + } + + static SealedHierarchy hierarchyOf(MixInResolver mixIns, Class clazz) { + return cache.byRoot.get(rootOf(mixIns, clazz)); } /** @@ -196,8 +289,12 @@ static void clearCache() { } /** - * Held in {@link ClassValue}s so that entries are collected along with the classes they describe - * rather than pinning a class loader, and replaced wholesale to clear. + * Held in a {@link ClassValue} so that entries are collected along with the classes they + * describe rather than pinning a class loader, and replaced wholesale to clear. + * + *

Keyed by root only. Which root a type belongs to can depend on the mapper's mix-ins, and so + * differ between mappers, but a root's name table is derived from the class files alone and is + * the same however that root opted in - so only the second half is memoized here. */ private static final class Cache { final ClassValue byRoot = new ClassValue<>() { @@ -206,12 +303,6 @@ protected SealedHierarchy computeValue(Class root) { return SealedHierarchy.of(root); } }; - final ClassValue byMember = new ClassValue<>() { - @Override - protected SealedHierarchy computeValue(Class type) { - return byRoot.get(rootOf(type)); - } - }; } private static volatile Cache cache = new Cache(); diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/TaggedBeanDeserializer.java b/src/main/java/com/github/pjfanning/jackson/sealed/TaggedBeanDeserializer.java index d14cfd9..4ee4b09 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/TaggedBeanDeserializer.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/TaggedBeanDeserializer.java @@ -16,10 +16,12 @@ final class TaggedBeanDeserializer extends ValueDeserializer { private final Class declaredClass; + private final SealedHierarchy hierarchy; private final ValueDeserializer delegate; - TaggedBeanDeserializer(Class declaredClass, ValueDeserializer delegate) { + TaggedBeanDeserializer(Class declaredClass, SealedHierarchy hierarchy, ValueDeserializer delegate) { this.declaredClass = declaredClass; + this.hierarchy = hierarchy; this.delegate = delegate; } @@ -36,7 +38,7 @@ public ValueDeserializer createContextual(DeserializationContext ctxt, BeanPr } @SuppressWarnings("unchecked") ValueDeserializer typed = (ValueDeserializer) contextual; - return new TaggedBeanDeserializer(declaredClass, typed); + return new TaggedBeanDeserializer(declaredClass, hierarchy, typed); } @Override @@ -49,9 +51,9 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) { if (tagged.typeName() == null) { return delegate.deserialize(tagged.parser(), ctxt); } - Class subtype = SealedTypes.hierarchyOf(declaredClass).resolve(declaredClass, tagged.typeName()); + Class subtype = hierarchy.resolve(declaredClass, tagged.typeName()); if (subtype == null) { - return TaggedObject.unresolved(ctxt, declaredClass, tagged.typeName()); + return TaggedObject.unresolved(ctxt, declaredClass, tagged.typeName(), hierarchy); } if (subtype == declaredClass) { return delegate.deserialize(tagged.parser(), ctxt); diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/TaggedObject.java b/src/main/java/com/github/pjfanning/jackson/sealed/TaggedObject.java index b4d0b79..e1bb1fc 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/TaggedObject.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/TaggedObject.java @@ -33,14 +33,14 @@ static TaggedObject split(JsonParser p, DeserializationContext ctxt) { } /** Reports a {@code @type} that names nothing in the hierarchy being read. */ - static T unresolved(DeserializationContext ctxt, Class baseClass, String typeName) { + static T unresolved(DeserializationContext ctxt, Class baseClass, String typeName, + SealedHierarchy hierarchy) { if (typeName == null) { return ctxt.reportInputMismatch(baseClass, "Expected a %s property naming an implementation of %s", SealedTypes.TYPE_PROPERTY_NAME, baseClass.getName()); } return ctxt.reportInputMismatch(baseClass, "'%s' is not an implementation of %s: no permitted subtype of " + "the sealed hierarchy rooted at %s is written under that %s name", - typeName, baseClass.getName(), SealedTypes.hierarchyOf(baseClass).root().getName(), - SealedTypes.TYPE_PROPERTY_NAME); + typeName, baseClass.getName(), hierarchy.root().getName(), SealedTypes.TYPE_PROPERTY_NAME); } } diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInFixtures.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInFixtures.java new file mode 100644 index 0000000..9cd6e22 --- /dev/null +++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInFixtures.java @@ -0,0 +1,153 @@ +package com.github.pjfanning.jackson.sealed.poly; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Sealed hierarchies whose classes cannot be changed - no marker anywhere on them, and nothing here + * referring to this module at all. They opt in through a mix-in registered on the mapper. + */ +public final class MixInFixtures { + + private MixInFixtures() { + } + + public sealed interface Vehicle permits Car, Truck, Bike { + } + + public record Car(int wheels) implements Vehicle { + } + + public record Truck(int axles, String load) implements Vehicle { + } + + public record Bike() implements Vehicle { + } + + public record Garage(Vehicle vehicle) { + } + + // implementations grouped into classes that do not enclose the root, to check the naming rules + // apply to a mixed-in hierarchy exactly as to a marked one + public sealed interface Cargo permits Hold.Item, Deck.Item { + } + + public static final class Hold { + private Hold() { + } + + public record Item(int n) implements Cargo { + } + } + + public static final class Deck { + private Deck() { + } + + public record Item(String s) implements Cargo { + } + } + + public record Manifest(Cargo cargo) { + } + + // three levels, so a mix-in can be put on a type part way down + public sealed interface Tier1 permits Tier2 { + } + + public sealed interface Tier2 extends Tier1 permits Tier3A, Tier3B { + } + + public record Tier3A(int a) implements Tier2 { + } + + public record Tier3B(String b) implements Tier2 { + } + + public record TierHolder(Tier2 value) { + } + + // a concrete sealed root, which is both a value and a base + public static sealed class Crate permits BigCrate { + private final int size; + + public Crate(int size) { + this.size = size; + } + + public int getSize() { + return size; + } + } + + public static final class BigCrate extends Crate { + private final String label; + + public BigCrate(int size, String label) { + super(size); + this.label = label; + } + + public String getLabel() { + return label; + } + } + + public record Shipment(Crate crate) { + } + + // an enum member, which stays Jackson's however the hierarchy opted in + public sealed interface Beacon permits Pulse, Colour { + } + + public record Pulse(int hz) implements Beacon { + } + + public enum Colour implements Beacon { + RED, GREEN + } + + public record BeaconHolder(Beacon beacon) { + } + + // not sealed at all - mixing the marker into it must be refused + public interface Loose { + } + + public record LooseImpl(int x) implements Loose { + } + + public record LooseHolder(Loose loose) { + } + + // sealed, but reopened below the root - mixing in does not relax that + public sealed interface Ajar permits Propped { + } + + public static non-sealed class Propped implements Ajar { + private final int x; + + public Propped(int x) { + this.x = x; + } + + public int getX() { + return x; + } + } + + public record AjarHolder(Ajar ajar) { + } + + // Jackson's own annotations already handle this one + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind") + @JsonSubTypes(@JsonSubTypes.Type(value = Signed.class, name = "Signed")) + public sealed interface Stamped permits Signed { + } + + public record Signed(int id) implements Stamped { + } + + public record StampedHolder(Stamped stamped) { + } +} diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInTest.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInTest.java new file mode 100644 index 0000000..ba269aa --- /dev/null +++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInTest.java @@ -0,0 +1,200 @@ +package com.github.pjfanning.jackson.sealed.poly; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.github.pjfanning.jackson.sealed.SealedPolymorphismModule; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Ajar; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.AjarHolder; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Beacon; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.BeaconHolder; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.BigCrate; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Bike; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Car; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Cargo; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Colour; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Crate; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Deck; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Garage; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Hold; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Loose; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.LooseHolder; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.LooseImpl; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Manifest; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Propped; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Pulse; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Shipment; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Signed; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Stamped; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.StampedHolder; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Tier2; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Tier3A; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.TierHolder; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Truck; +import com.github.pjfanning.jackson.sealed.poly.MixInFixtures.Vehicle; +import com.github.pjfanning.jackson.sealed.poly.mixin.VehicleMixIns; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Opting a hierarchy in with a Jackson mix-in, for classes that cannot be changed to extend the + * marker themselves. + * + *

A mix-in does not change the type on the JVM, so the marker is not inherited by the + * implementations the way it would be if the base extended it. Only the root is opted in directly, + * and the module has to read that from the mapper's configuration rather than from the classes. + */ +class MixInTest { + + private static ObjectMapper mapperWith(Class target, Class mixIn) { + return JsonMapper.builder() + .addModule(new SealedPolymorphismModule()) + .addMixIn(target, mixIn) + .build(); + } + + private final ObjectMapper mapper = JsonMapper.builder() + .addModule(new SealedPolymorphismModule()) + .addMixIn(Vehicle.class, VehicleMixIns.OptIn.class) + .addMixIn(Cargo.class, VehicleMixIns.OptIn.class) + .addMixIn(Crate.class, VehicleMixIns.OptIn.class) + .addMixIn(Beacon.class, VehicleMixIns.OptIn.class) + .build(); + + private T roundTrip(ObjectMapper m, T value, Class clazz) { + return m.readValue(m.writeValueAsString(value), clazz); + } + + private static Throwable rootCause(Throwable error) { + Throwable cause = error.getCause(); + return (cause == null || cause == error) ? error : rootCause(cause); + } + + @Test + void tagsAHierarchyOptedInByMixIn() { + assertThat(mapper.writeValueAsString(new Garage(new Car(4)))) + .isEqualTo("{\"vehicle\":{\"@type\":\"Car\",\"wheels\":4}}"); + assertThat(mapper.writeValueAsString(new Garage(new Bike()))) + .isEqualTo("{\"vehicle\":{\"@type\":\"Bike\"}}"); + } + + @Test + void roundTripsAHierarchyOptedInByMixIn() { + assertThat(roundTrip(mapper, new Garage(new Car(4)), Garage.class)).isEqualTo(new Garage(new Car(4))); + assertThat(roundTrip(mapper, new Garage(new Truck(3, "sand")), Garage.class)) + .isEqualTo(new Garage(new Truck(3, "sand"))); + assertThat(roundTrip(mapper, new Garage(new Bike()), Garage.class)).isEqualTo(new Garage(new Bike())); + } + + @Test + void readsAMixedInBaseTypeAtTheTopLevel() { + assertThat(mapper.readValue("{\"@type\":\"Car\",\"wheels\":4}", Vehicle.class)).isEqualTo(new Car(4)); + } + + /** The same classes through a mapper without the mix-in must be untouched. */ + @Test + void leavesTheSameHierarchyAloneWithoutTheMixIn() { + ObjectMapper plain = JsonMapper.builder().addModule(new SealedPolymorphismModule()).build(); + assertThat(plain.writeValueAsString(new Car(4))).isEqualTo("{\"wheels\":4}"); + assertThat(plain.readValue("{\"wheels\":4}", Car.class)).isEqualTo(new Car(4)); + } + + /** A mix-in that does not carry the marker opts nothing in. */ + @Test + void ignoresAMixInThatDoesNotCarryTheMarker() { + ObjectMapper m = mapperWith(Vehicle.class, VehicleMixIns.Unrelated.class); + assertThat(m.writeValueAsString(new Car(4))).isEqualTo("{\"wheels\":4}"); + } + + @Test + void appliesTheSameNamingRulesToAMixedInHierarchy() { + assertThat(mapper.writeValueAsString(new Manifest(new Hold.Item(1)))) + .isEqualTo("{\"cargo\":{\"@type\":\"Hold$Item\",\"n\":1}}"); + assertThat(mapper.writeValueAsString(new Manifest(new Deck.Item("x")))) + .isEqualTo("{\"cargo\":{\"@type\":\"Deck$Item\",\"s\":\"x\"}}"); + assertThat(roundTrip(mapper, new Manifest(new Deck.Item("x")), Manifest.class)) + .isEqualTo(new Manifest(new Deck.Item("x"))); + } + + @Test + void dispatchesAtAMixedInConcreteRoot() { + assertThat(mapper.writeValueAsString(new Shipment(new Crate(2)))) + .isEqualTo("{\"crate\":{\"@type\":\"Crate\",\"size\":2}}"); + String json = mapper.writeValueAsString(new Shipment(new BigCrate(3, "L"))); + assertThat(json).isEqualTo("{\"crate\":{\"@type\":\"BigCrate\",\"size\":3,\"label\":\"L\"}}"); + Crate crate = mapper.readValue(json, Shipment.class).crate(); + assertThat(crate).isInstanceOf(BigCrate.class); + assertThat(((BigCrate) crate).getLabel()).isEqualTo("L"); + } + + @Test + void leavesEnumsOfAMixedInHierarchyToJackson() { + assertThat(mapper.writeValueAsString(new BeaconHolder(Colour.RED))) + .isEqualTo("{\"beacon\":\"RED\"}"); + assertThat(roundTrip(mapper, new BeaconHolder(new Pulse(50)), BeaconHolder.class)) + .isEqualTo(new BeaconHolder(new Pulse(50))); + } + + /** A mix-in on a type part way down a hierarchy makes that type the root. */ + @Test + void treatsAMixedInMidHierarchyTypeAsTheRoot() { + ObjectMapper m = mapperWith(Tier2.class, VehicleMixIns.OptIn.class); + assertThat(m.writeValueAsString(new TierHolder(new Tier3A(1)))) + .isEqualTo("{\"value\":{\"@type\":\"Tier3A\",\"a\":1}}"); + assertThat(roundTrip(m, new TierHolder(new Tier3A(1)), TierHolder.class)) + .isEqualTo(new TierHolder(new Tier3A(1))); + } + + @Test + void handlesMarkedAndMixedInHierarchiesInTheSameMapper() { + ObjectMapper m = JsonMapper.builder() + .addModule(new SealedPolymorphismModule()) + .addMixIn(Vehicle.class, VehicleMixIns.OptIn.class) + .build(); + assertThat(m.writeValueAsString(new Fixtures.Owner("ann", new Fixtures.Dog("rex")))) + .isEqualTo("{\"name\":\"ann\",\"pet\":{\"@type\":\"Dog\",\"name\":\"rex\"}}"); + assertThat(m.writeValueAsString(new Garage(new Car(4)))) + .isEqualTo("{\"vehicle\":{\"@type\":\"Car\",\"wheels\":4}}"); + } + + /** Mixing in does not relax the closed-hierarchy rule. */ + @Test + void refusesAMixedInHierarchyThatIsNotSealed() { + ObjectMapper m = mapperWith(Loose.class, VehicleMixIns.OptIn.class); + assertThatThrownBy(() -> m.writeValueAsString(new LooseHolder(new LooseImpl(1)))) + .satisfies(error -> assertThat(String.valueOf(rootCause(error).getMessage())) + .contains("Loose") + .contains("is not sealed")); + } + + @Test + void refusesAMixedInHierarchyReopenedBelowTheRoot() { + ObjectMapper m = mapperWith(Ajar.class, VehicleMixIns.OptIn.class); + assertThatThrownBy(() -> m.writeValueAsString(new AjarHolder(new Propped(1)))) + .satisfies(error -> assertThat(String.valueOf(rootCause(error).getMessage())) + .contains("Propped") + .contains("neither sealed nor final")); + } + + /** A mix-in supplying @JsonTypeInfo hands the hierarchy back to Jackson, marker or not. */ + @Test + void standsDownWhenTheMixInSuppliesJsonTypeInfo() { + ObjectMapper m = mapperWith(Vehicle.class, VehicleMixIns.OptInThenDefer.class); + // Jackson's own handling writes the type id it was told to; the point is that it, and not + // this module, is in charge - so there is a "kind" and no "@type" + assertThat(m.writeValueAsString(new Garage(new Car(4)))) + .contains("\"kind\":") + .doesNotContain("@type"); + } + + /** A hierarchy Jackson already owns by its own annotations is untouched by a marker mix-in. */ + @Test + void leavesAnAnnotatedHierarchyToJackson() { + ObjectMapper m = mapperWith(Stamped.class, VehicleMixIns.OptIn.class); + assertThat(m.writeValueAsString(new StampedHolder(new Signed(7)))) + .isEqualTo("{\"stamped\":{\"kind\":\"Signed\",\"id\":7}}"); + assertThat(roundTrip(m, new StampedHolder(new Signed(7)), StampedHolder.class)) + .isEqualTo(new StampedHolder(new Signed(7))); + } +} diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/mixin/VehicleMixIns.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/mixin/VehicleMixIns.java new file mode 100644 index 0000000..ee5145b --- /dev/null +++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/mixin/VehicleMixIns.java @@ -0,0 +1,32 @@ +package com.github.pjfanning.jackson.sealed.poly.mixin; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.github.pjfanning.jackson.sealed.SealedPolymorphismSupport; + +/** + * What the owner of the mapper supplies for a hierarchy they cannot change, in their own package - + * which is the whole point. + * + *

Each mix-in carries the marker and nothing else. None of them extends the hierarchy it is mixed + * into, and none of them could: a sealed type's {@code permits} clause names its subtypes, and + * someone who cannot change those classes cannot add themselves to it. Jackson never requires a + * mix-in to be a subtype of what it is mixed into - it only harvests from it, and this module only + * asks whether the mix-in carries the marker. + */ +public final class VehicleMixIns { + + private VehicleMixIns() { + } + + public interface OptIn extends SealedPolymorphismSupport { + } + + /** Carries no marker, so mixing it in opts nothing in. */ + public interface Unrelated { + } + + /** Opts in and hands the hierarchy straight back to Jackson, which is a contradiction. */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind") + public interface OptInThenDefer extends SealedPolymorphismSupport { + } +}