diff --git a/README.md b/README.md index 005309e..becd22b 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 95 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,42 @@ 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 +[registration](#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 the hierarchy's root with the module instead: + +```java +SealedPolymorphismModule module = new SealedPolymorphismModule() + .registerSealedInterfaceOrClass(Animal.class) + .registerSealedInterfaceOrClass(Shape.class); + +ObjectMapper mapper = JsonMapper.builder().addModule(module).build(); +``` + +Call `registerSealedInterfaceOrClass` once per hierarchy root, as often as needed. Registering adds +to what the module already handles through the marker rather than replacing it, so marked and +registered hierarchies work through the same mapper. + +A registered hierarchy is handled exactly as a marked one: same `@type` names, same reading, same +requirement that it be sealed. Register the *root* — its implementations follow from the `permits` +clause and do not need registering themselves. Registering a type part way down a hierarchy is +allowed and makes that type the root, so names are derived relative to it and its siblings are left +alone. + +`registerSealedInterfaceOrClass` rejects anything it cannot handle there and then, rather than +later when Jackson first meets the type: + +| Argument | Result | +| --- | --- | +| `null` | error | +| not `sealed` | error — registration replaces the marker, not the closed-hierarchy requirement | +| an `enum` | error — enums are left to Jackson; register the sealed interface it implements instead | +| carries `@JsonTypeInfo` | error — that already tells Jackson how to write and read the hierarchy | ## How names are derived @@ -208,12 +242,13 @@ performance but not behaviour. ## Tests -76 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`: +95 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/RegisteredTypeTest` | Hierarchies opted in by registration 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..ee23e82 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializerModifier.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializerModifier.java @@ -15,15 +15,21 @@ final class SealedPolymorphismDeserializerModifier extends ValueDeserializerModi private static final long serialVersionUID = 1L; + private final transient SealedTypes types; + + SealedPolymorphismDeserializerModifier(SealedTypes types) { + this.types = types; + } + @Override public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanDescription.Supplier beanDescRef, BeanDeserializerBuilder builder) { Class rawClass = beanDescRef.getBeanClass(); - if (!SealedTypes.isMarked(rawClass)) { + if (!types.isOptedIn(rawClass)) { return builder; } - SealedTypes.checkNoConflictingJsonTypeInfo(rawClass); - if (SealedTypes.isSupported(rawClass) && SealedTypes.isConcrete(rawClass)) { + types.checkNoConflictingJsonTypeInfo(rawClass); + if (types.isSupported(rawClass) && SealedTypes.isConcrete(rawClass)) { builder.addIgnorable(SealedTypes.TYPE_PROPERTY_NAME); } return builder; @@ -33,12 +39,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 (!types.needsSubtypeDispatch(rawClass)) { return deserializer; } @SuppressWarnings("unchecked") ValueDeserializer delegate = (ValueDeserializer) deserializer; - return new TaggedBeanDeserializer(rawClass, delegate); + return new TaggedBeanDeserializer(rawClass, types.hierarchyOf(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..87da315 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializers.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismDeserializers.java @@ -9,15 +9,23 @@ /** Binds the dispatching deserializer to every base type of a marked hierarchy. */ final class SealedPolymorphismDeserializers extends Deserializers.Base { + private final SealedTypes types; + + SealedPolymorphismDeserializers(SealedTypes types) { + this.types = types; + } + @Override 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, types.hierarchyOf(rawClass)) + : null; } @Override public boolean hasDeserializerFor(DeserializationConfig config, Class valueType) { - return SealedTypes.isBaseType(valueType); + return types.isBaseType(valueType); } } diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismModule.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismModule.java index 9323c5d..c282b01 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismModule.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismModule.java @@ -1,11 +1,14 @@ package com.github.pjfanning.jackson.sealed; +import java.util.Set; + import tools.jackson.core.Version; import tools.jackson.databind.JacksonModule; /** - * Jackson module that gives automatic polymorphic serialization to {@code sealed} hierarchies - * marked with {@link SealedPolymorphismSupport}. + * Jackson module that gives automatic polymorphic serialization to {@code sealed} hierarchies. + * + *

A hierarchy opts in either by extending {@link SealedPolymorphismSupport}: * *

{@code
  * ObjectMapper mapper = JsonMapper.builder()
@@ -13,13 +16,73 @@
  *         .build();
  * }
* - *

The module only ever looks at types carrying the marker, so registering it has no effect on - * anything else an application serializes. + *

or by being registered here, for a hierarchy whose source cannot be changed to extend the + * marker - one from a library, or generated code. A module handles both at once: registering a type + * adds to the hierarchies it already picks up from the marker, it does not replace them. + * + *

{@code
+ * SealedPolymorphismModule module = new SealedPolymorphismModule()
+ *         .registerSealedInterfaceOrClass(Animal.class)
+ *         .registerSealedInterfaceOrClass(Shape.class);
+ *
+ * ObjectMapper mapper = JsonMapper.builder().addModule(module).build();
+ * }
+ * + *

A registered type is handled exactly as if it carried the marker, and is held to the same + * requirement: it must be {@code sealed}, and so must every type below it that is not {@code final}. + * Anything that cannot be handled is rejected by + * {@link #registerSealedInterfaceOrClass(Class) registerSealedInterfaceOrClass} itself, rather than + * later when Jackson first meets the type. + * + *

Register the root of a hierarchy; its implementations follow from the root's + * {@code permits} clause and do not need registering themselves. Registering a type part way down a + * hierarchy is allowed, and makes that type the root - names are then derived relative to it, and + * its siblings are not handled. + * + *

The module only ever looks at types that have opted in one of these two ways, so registering + * it has no effect on anything else an application serializes. * * @see SealedPolymorphismSupport */ public class SealedPolymorphismModule extends JacksonModule { + private final SealedTypes types = new SealedTypes(); + + /** + * A module handling every hierarchy that extends {@link SealedPolymorphismSupport}. Add + * hierarchies that do not with + * {@link #registerSealedInterfaceOrClass(Class) registerSealedInterfaceOrClass}. + */ + public SealedPolymorphismModule() { + } + + /** + * Handles the given sealed interface or class as if it carried + * {@link SealedPolymorphismSupport}, on top of everything that does carry it. + * + *

Call it once per hierarchy root, as often as needed. Register the root: its + * implementations follow from the root's {@code permits} clause and do not need registering + * themselves. Registering the same type twice is harmless. + * + *

Registering a type part way down a hierarchy is allowed, and makes that type the root - + * names are then derived relative to it, and its siblings are not handled. + * + * @param sealedType root of a sealed hierarchy to handle + * @return this module, so calls can be chained + * @throws IllegalArgumentException if the type is null, is not {@code sealed}, is an enum, or + * carries {@code @JsonTypeInfo} - which already tells Jackson + * how to write and read the hierarchy + */ + public SealedPolymorphismModule registerSealedInterfaceOrClass(Class sealedType) { + types.register(sealedType); + return this; + } + + /** The hierarchy roots registered with this module, beyond those carrying the marker. */ + public Set> registeredTypes() { + return types.registeredRoots(); + } + @Override public String getModuleName() { return "SealedPolymorphismModule"; @@ -30,11 +93,21 @@ public Version version() { return PackageVersion.VERSION; } + /** + * Two modules registering different types are different modules. Jackson drops a module whose + * registration id it has already seen, so without this a second module carrying extra + * registrations would be silently ignored. + */ + @Override + public Object getRegistrationId() { + return getClass().getName() + types.registeredRoots(); + } + @Override public void setupModule(SetupContext context) { - context.addSerializerModifier(new SealedPolymorphismSerializerModifier()); - context.addDeserializers(new SealedPolymorphismDeserializers()); - context.addDeserializerModifier(new SealedPolymorphismDeserializerModifier()); + context.addSerializerModifier(new SealedPolymorphismSerializerModifier(types)); + context.addDeserializers(new SealedPolymorphismDeserializers(types)); + context.addDeserializerModifier(new SealedPolymorphismDeserializerModifier(types)); } /** 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..27ba724 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSerializerModifier.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSerializerModifier.java @@ -13,21 +13,27 @@ final class SealedPolymorphismSerializerModifier extends ValueSerializerModifier private static final long serialVersionUID = 1L; + private final transient SealedTypes types; + + SealedPolymorphismSerializerModifier(SealedTypes types) { + this.types = types; + } + @Override public ValueSerializer modifySerializer(SerializationConfig config, BeanDescription.Supplier beanDescRef, ValueSerializer serializer) { Class rawClass = beanDescRef.getBeanClass(); - if (!SealedTypes.isMarked(rawClass)) { + if (!types.isOptedIn(rawClass)) { return serializer; } - SealedTypes.checkNoConflictingJsonTypeInfo(rawClass); + types.checkNoConflictingJsonTypeInfo(rawClass); // a base type is never written directly - only the implementation dispatched to at runtime - if (!SealedTypes.isSupported(rawClass) || !SealedTypes.isConcrete(rawClass)) { + if (!types.isSupported(rawClass) || !SealedTypes.isConcrete(rawClass)) { return serializer; } @SuppressWarnings("unchecked") ValueSerializer delegate = (ValueSerializer) serializer; - return new TypeTaggedSerializer(SealedTypes.hierarchyOf(rawClass).nameOf(rawClass), delegate); + return new TypeTaggedSerializer(types.hierarchyOf(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..ebe4584 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java @@ -52,11 +52,25 @@ * doing so, so an enum member carries no {@code @type} name - which means a value of one cannot be * read back through the hierarchy's base type, though it reads normally where the property is * declared as the enum type itself. For a stateless member that does round trip through the base, - * use a record with no components. Putting this marker on an enum has no effect. + * use a record with no components. + * + *

Hierarchies you cannot change

+ * + *

A hierarchy whose source you do not control - from a library, or generated - can be opted in by + * registering it with the module instead of extending this interface: + * + *

{@code
+ * new SealedPolymorphismModule()
+ *         .registerSealedInterfaceOrClass(Animal.class)
+ *         .registerSealedInterfaceOrClass(Shape.class)
+ * }
+ * + *

A registered hierarchy is handled identically to a marked one, and is held to the same + * requirement that it be sealed. See {@link SealedPolymorphismModule} for the details. * *

Deferring to Jackson

* - *

{@code @JsonTypeInfo} on the base of a marked hierarchy switches this module off for that + *

{@code @JsonTypeInfo} on the base of a handled hierarchy switches this module off for that * hierarchy, leaving Jackson's own polymorphic handling in sole charge. The same annotation on an * implementation rather than on the base is a configuration error: Jackson would treat the * annotated class as a polymorphic base in its own right and demand a type id that nothing in a 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..0e1ac2c 100644 --- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java +++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java @@ -2,8 +2,11 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import com.fasterxml.jackson.annotation.JsonTypeInfo; @@ -11,8 +14,14 @@ /** * Naming rules and policy behind {@link SealedPolymorphismSupport}. * - *

Every path through the module asks {@link #isMarked} first, so a class that does not carry the - * marker never reaches any of the reflection here, and an application's own types are untouched. + *

A hierarchy opts in either by extending the marker or by being registered with the module, so + * which types are handled depends on how the module was configured - that part is per instance. The + * name table of a hierarchy is derived from the class files alone and is the same however the + * hierarchy opted in, so that part is cached globally. + * + *

Every path through the module asks {@link #isOptedIn} first, and a class that carries neither + * the marker nor a registration answers in constant time, so an application's own types are + * untouched. */ final class SealedTypes { @@ -21,37 +30,88 @@ final class SealedTypes { private static final Class MARKER = SealedPolymorphismSupport.class; - private SealedTypes() { + /** Keyed by root, and independent of how that root opted in. */ + private static volatile ClassValue hierarchiesByRoot = newHierarchyCache(); + + private volatile Set> registeredRoots = Set.of(); + private volatile ClassValue>> rootsByMember = newRootCache(); + + /** + * Registers a hierarchy root, to be handled as if it carried the marker. Registering the same + * type twice is harmless. + * + *

Roots may be added at any point, so anything already worked out about which hierarchy a + * type belongs to is discarded - a type that resolved to nothing, or to a root further down, + * may now resolve differently. + */ + synchronized void register(Class root) { + checkRegistrable(root); + if (registeredRoots.contains(root)) { + return; + } + Set> updated = new LinkedHashSet<>(registeredRoots); + updated.add(root); + registeredRoots = Collections.unmodifiableSet(updated); + rootsByMember = newRootCache(); } /** - * True for a type that opts into this module - the marker itself does not count, so that the - * marker interface can be referred to without being treated as a hierarchy. + * Checks a type may be registered as a hierarchy root. Registration is the way in for a + * hierarchy whose source cannot be changed to extend the marker, so it accepts anything the + * marker would - but no more: the hierarchy still has to be sealed. */ - static boolean isMarked(Class clazz) { - return clazz != null && clazz != MARKER && MARKER.isAssignableFrom(clazz); + private static void checkRegistrable(Class root) { + if (root == null) { + throw new IllegalArgumentException("Cannot register a null type as a sealed hierarchy root."); + } + if (enumClassOf(root) != null) { + throw new IllegalArgumentException(root.getName() + " is an enum, so cannot be registered as a " + + "hierarchy root. Enums are left to Jackson, which writes them as strings. Register the sealed " + + "interface it implements instead - the enum itself stays a string either way."); + } + if (!root.isSealed()) { + throw new IllegalArgumentException(root.getName() + " cannot be registered with " + + SealedPolymorphismModule.class.getSimpleName() + " because it is not sealed. Only sealed " + + "hierarchies are supported: registration replaces the " + MARKER.getSimpleName() + + " marker, not the requirement that the hierarchy be closed."); + } + if (root.getAnnotation(JsonTypeInfo.class) != null) { + throw new IllegalArgumentException(root.getName() + " cannot be registered with " + + SealedPolymorphismModule.class.getSimpleName() + " because it carries @JsonTypeInfo. That " + + "annotation already tells Jackson how to write and read the hierarchy, so registering it here " + + "would ask for two type properties at once. Remove the annotation to use " + + TYPE_PROPERTY_NAME + ", or leave it and do not register the type."); + } + } + + /** The types registered with this module, in the order they were given. */ + Set> registeredRoots() { + return registeredRoots; + } + + /** + * True for a type in a hierarchy this module handles, whether it opted in through the marker or + * through registration. + */ + boolean isOptedIn(Class clazz) { + return clazz != null && rootsByMember.get(clazz).isPresent(); } /** - * True for a marked type this module should handle. A hierarchy whose root carries + * True for an opted-in type this module should handle. A hierarchy whose root carries * {@code @JsonTypeInfo} is left to Jackson's own polymorphic handling rather than being tagged * twice. */ - static boolean isSupported(Class clazz) { + boolean isSupported(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(); - } - - /** True for a type that can hold a value of its own, so can carry a name of its own. */ - static boolean isConcrete(Class clazz) { - return !clazz.isInterface() && !Modifier.isAbstract(clazz.getModifiers()); + return isOptedIn(clazz) && enumClassOf(clazz) == null && !hierarchyOf(clazz).isJacksonOwned(); } /** * True for a type that can only be dispatched on, never instantiated - an interface or abstract * class. Reading one means reading whatever its {@code @type} names. */ - static boolean isBaseType(Class clazz) { + boolean isBaseType(Class clazz) { return isSupported(clazz) && !isConcrete(clazz); } @@ -61,25 +121,108 @@ static boolean isBaseType(Class clazz) { * one of these has to dispatch rather than read straight through to the bean, or a subclass * would silently be read back as the type the property was declared as. */ - static boolean needsSubtypeDispatch(Class clazz) { + boolean needsSubtypeDispatch(Class clazz) { return isSupported(clazz) && isConcrete(clazz) && clazz.isSealed(); } /** * {@code @JsonTypeInfo} on an implementation rather than on the root cannot be honoured * alongside {@code @type}: Jackson treats the annotated class as a polymorphic base in its own - * right, so reading it demands that annotation's type id, which nothing in a marked hierarchy + * right, so reading it demands that annotation's type id, which nothing in a handled hierarchy * ever writes. The combination is reported rather than left to produce JSON that cannot be read * back. */ - static void checkNoConflictingJsonTypeInfo(Class clazz) { + void checkNoConflictingJsonTypeInfo(Class clazz) { if (isSupported(clazz) && clazz.getAnnotation(JsonTypeInfo.class) != null) { Class root = hierarchyOf(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 " - + "remove it to use " + TYPE_PROPERTY_NAME + "."); + throw new IllegalArgumentException(clazz.getName() + " carries @JsonTypeInfo but belongs to the sealed " + + "hierarchy rooted at " + root.getName() + ", which " + SealedPolymorphismModule.class.getSimpleName() + + " handles. Move the annotation to " + root.getSimpleName() + " to use Jackson's polymorphic " + + "handling for the whole hierarchy, or remove it to use " + TYPE_PROPERTY_NAME + "."); + } + } + + /** + * The top of the hierarchy {@code clazz} belongs to. The {@code @JsonTypeInfo} opt-out is read + * from the root rather than from {@code clazz} itself so that both halves of the module agree: + * an annotation on one implementation governs that implementation's own subtypes, and must not + * silently switch off tagging for it while the base is still dispatching on {@code @type}. + */ + Class rootOf(Class clazz) { + return rootsByMember.get(clazz).orElseThrow(() -> new IllegalArgumentException(clazz.getName() + + " does not implement " + MARKER.getName() + ", and is not registered with " + + SealedPolymorphismModule.class.getSimpleName() + ".")); + } + + /** The resolved hierarchy {@code clazz} belongs to, built once per root. */ + SealedHierarchy hierarchyOf(Class clazz) { + return hierarchiesByRoot.get(rootOf(clazz)); + } + + private ClassValue>> newRootCache() { + return new ClassValue<>() { + @Override + protected Optional> computeValue(Class type) { + return findRoot(type); + } + }; + } + + private Optional> findRoot(Class clazz) { + if (clazz == MARKER) { + return Optional.empty(); + } + // the common case: no marker anywhere above, and nothing registered to reach either + if (!MARKER.isAssignableFrom(clazz) && registeredRoots.isEmpty()) { + return Optional.empty(); + } + Set> optedIn = new LinkedHashSet<>(); + collectOptedIn(clazz, optedIn, new HashSet<>()); + if (optedIn.isEmpty()) { + return Optional.empty(); + } + Class root = null; + for (Class candidate : optedIn) { + if (root == null || candidate.isAssignableFrom(root)) { + root = candidate; + } + } + List disjoint = new ArrayList<>(); + for (Class candidate : optedIn) { + if (!root.isAssignableFrom(candidate)) { + disjoint.add(candidate.getName()); + } + } + if (!disjoint.isEmpty()) { + throw new IllegalArgumentException(clazz.getName() + " belongs to more than one sealed hierarchy handled " + + "by " + SealedPolymorphismModule.class.getSimpleName() + " - " + root.getName() + " and " + + String.join(", ", disjoint) + " - so there is no single hierarchy whose names it could be " + + "written under. Opt only one of them in."); } + return Optional.of(root); + } + + /** + * Collects every supertype that has opted in. Unlike the marker, a registration names one type + * rather than everything below it, so the walk cannot stop at a supertype that has not opted in + * - a registered root may sit above several plain classes. + */ + private void collectOptedIn(Class clazz, Set> into, Set> visited) { + if (clazz == null || clazz == Object.class || clazz == MARKER || !visited.add(clazz)) { + return; + } + if (MARKER.isAssignableFrom(clazz) || registeredRoots.contains(clazz)) { + into.add(clazz); + } + collectOptedIn(clazz.getSuperclass(), into, visited); + for (Class iface : clazz.getInterfaces()) { + collectOptedIn(iface, into, visited); + } + } + + /** True for a type that can hold a value of its own, so can carry a name of its own. */ + static boolean isConcrete(Class clazz) { + return !clazz.isInterface() && !Modifier.isAbstract(clazz.getModifiers()); } /** @@ -135,84 +278,24 @@ static Class enumClassOf(Class clazz) { return (superclass != null && superclass.isEnum()) ? superclass : null; } - /** - * The top of the marked hierarchy {@code clazz} belongs to. The {@code @JsonTypeInfo} opt-out is - * read from the root rather than from {@code clazz} itself so that both halves of the module - * agree: an annotation on one implementation governs that implementation's own subtypes, and - * must not silently switch off tagging for it while the base is still dispatching on - * {@code @type}. - */ - static Class rootOf(Class clazz) { - Set> marked = new LinkedHashSet<>(); - collectMarked(clazz, marked); - Class root = null; - for (Class candidate : marked) { - 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) { - if (!root.isAssignableFrom(candidate)) { - disjoint.add(candidate.getName()); - } - } - 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."); - } - return root; - } - - private static void collectMarked(Class clazz, Set> into) { - if (clazz == null || clazz == MARKER || !MARKER.isAssignableFrom(clazz)) { - return; - } - if (!into.add(clazz)) { - return; - } - collectMarked(clazz.getSuperclass(), into); - for (Class iface : clazz.getInterfaces()) { - collectMarked(iface, into); - } - } - - /** The resolved hierarchy {@code clazz} belongs to, built once per root. */ - static SealedHierarchy hierarchyOf(Class clazz) { - return cache.byMember.get(clazz); - } - /** * Empties the cache of resolved hierarchies. The cache is only a memo of what is derived from * the class files, so clearing it affects performance but not behaviour. */ static void clearCache() { - cache = new Cache(); + hierarchiesByRoot = newHierarchyCache(); } /** - * 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. */ - private static final class Cache { - final ClassValue byRoot = new ClassValue<>() { + private static ClassValue newHierarchyCache() { + return new ClassValue<>() { @Override 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/SealedTypesTest.java b/src/test/java/com/github/pjfanning/jackson/sealed/SealedTypesTest.java index 36e68a3..6baf286 100644 --- a/src/test/java/com/github/pjfanning/jackson/sealed/SealedTypesTest.java +++ b/src/test/java/com/github/pjfanning/jackson/sealed/SealedTypesTest.java @@ -13,6 +13,9 @@ */ class SealedTypesTest { + /** No registrations - these cover the marker path. See RegisteredTypeTest for the other one. */ + private final SealedTypes types = new SealedTypes(); + @Test void derivesPrefixesLongestFirstForATopLevelRoot() { assertThat(SealedTypes.prefixesFor("com.example.Dup")) @@ -77,40 +80,40 @@ void neverDerivesAFullyQualifiedName() { @Test void findsTheTopOfTheHierarchy() { - assertThat(SealedTypes.rootOf(Fixtures.Dog.class)).isEqualTo(Fixtures.Animal.class); - assertThat(SealedTypes.rootOf(Fixtures.Animal.class)).isEqualTo(Fixtures.Animal.class); - assertThat(SealedTypes.rootOf(Fixtures.Twig.class)).isEqualTo(Fixtures.Node.class); - assertThat(SealedTypes.rootOf(Fixtures.Status.class)).isEqualTo(Fixtures.Signal.class); + assertThat(types.rootOf(Fixtures.Dog.class)).isEqualTo(Fixtures.Animal.class); + assertThat(types.rootOf(Fixtures.Animal.class)).isEqualTo(Fixtures.Animal.class); + assertThat(types.rootOf(Fixtures.Twig.class)).isEqualTo(Fixtures.Node.class); + assertThat(types.rootOf(Fixtures.Status.class)).isEqualTo(Fixtures.Signal.class); } @Test - void treatsTheMarkerItselfAsUnmarked() { - assertThat(SealedTypes.isMarked(SealedPolymorphismSupport.class)).isFalse(); - assertThat(SealedTypes.isMarked(Fixtures.Dog.class)).isTrue(); - assertThat(SealedTypes.isMarked(Fixtures.PlainDog.class)).isFalse(); + void treatsTheMarkerItselfAsNotOptedIn() { + assertThat(types.isOptedIn(SealedPolymorphismSupport.class)).isFalse(); + assertThat(types.isOptedIn(Fixtures.Dog.class)).isTrue(); + assertThat(types.isOptedIn(Fixtures.PlainDog.class)).isFalse(); } @Test void standsDownForAHierarchyThatCarriesJsonTypeInfo() { - assertThat(SealedTypes.isMarked(Fixtures.Cheque.class)).isTrue(); - assertThat(SealedTypes.isSupported(Fixtures.Cheque.class)).isFalse(); - assertThat(SealedTypes.hierarchyOf(Fixtures.Cheque.class).isJacksonOwned()).isTrue(); + assertThat(types.isOptedIn(Fixtures.Cheque.class)).isTrue(); + assertThat(types.isSupported(Fixtures.Cheque.class)).isFalse(); + assertThat(types.hierarchyOf(Fixtures.Cheque.class).isJacksonOwned()).isTrue(); } @Test void tellsBaseTypesFromValues() { - assertThat(SealedTypes.isBaseType(Fixtures.Animal.class)).isTrue(); - assertThat(SealedTypes.isBaseType(Fixtures.Shape.class)).isTrue(); - assertThat(SealedTypes.isBaseType(Fixtures.Dog.class)).isFalse(); - assertThat(SealedTypes.isBaseType(Fixtures.Node.class)).isFalse(); + assertThat(types.isBaseType(Fixtures.Animal.class)).isTrue(); + assertThat(types.isBaseType(Fixtures.Shape.class)).isTrue(); + assertThat(types.isBaseType(Fixtures.Dog.class)).isFalse(); + assertThat(types.isBaseType(Fixtures.Node.class)).isFalse(); } @Test void dispatchesOnlyAtAConcreteTypeThatIsExtended() { - assertThat(SealedTypes.needsSubtypeDispatch(Fixtures.Node.class)).isTrue(); - assertThat(SealedTypes.needsSubtypeDispatch(Fixtures.Branch.class)).isTrue(); - assertThat(SealedTypes.needsSubtypeDispatch(Fixtures.Twig.class)).isFalse(); - assertThat(SealedTypes.needsSubtypeDispatch(Fixtures.Dog.class)).isFalse(); + assertThat(types.needsSubtypeDispatch(Fixtures.Node.class)).isTrue(); + assertThat(types.needsSubtypeDispatch(Fixtures.Branch.class)).isTrue(); + assertThat(types.needsSubtypeDispatch(Fixtures.Twig.class)).isFalse(); + assertThat(types.needsSubtypeDispatch(Fixtures.Dog.class)).isFalse(); } @Test @@ -123,7 +126,7 @@ void findsTheEnumAConstantBelongsTo() { /** An enum permitted by the root is Jackson's to write, so it takes no name in the table. */ @Test void givesNoNameToAnEnumMember() { - SealedHierarchy hierarchy = SealedTypes.hierarchyOf(Fixtures.Signal.class); + SealedHierarchy hierarchy = types.hierarchyOf(Fixtures.Signal.class); assertThat(hierarchy.resolve(Fixtures.Signal.class, "Status")).isNull(); assertThat(hierarchy.resolve(Fixtures.Signal.class, "Status$IDLE")).isNull(); assertThat(hierarchy.resolve(Fixtures.Signal.class, "IDLE")).isNull(); @@ -133,29 +136,29 @@ void givesNoNameToAnEnumMember() { @Test void doesNotHandleAnEnumMember() { - assertThat(SealedTypes.isMarked(Fixtures.Status.class)).isTrue(); - assertThat(SealedTypes.isSupported(Fixtures.Status.class)).isFalse(); - assertThat(SealedTypes.isBaseType(Fixtures.Status.class)).isFalse(); - assertThat(SealedTypes.needsSubtypeDispatch(Fixtures.Status.class)).isFalse(); + assertThat(types.isOptedIn(Fixtures.Status.class)).isTrue(); + assertThat(types.isSupported(Fixtures.Status.class)).isFalse(); + assertThat(types.isBaseType(Fixtures.Status.class)).isFalse(); + assertThat(types.needsSubtypeDispatch(Fixtures.Status.class)).isFalse(); } @Test void resolvesANameToItsImplementation() { - SealedHierarchy hierarchy = SealedTypes.hierarchyOf(Fixtures.Animal.class); + SealedHierarchy hierarchy = types.hierarchyOf(Fixtures.Animal.class); assertThat(hierarchy.resolve(Fixtures.Animal.class, "Dog")).isEqualTo(Fixtures.Dog.class); } /** A name only resolves where the declared type could actually hold the result. */ @Test void refusesANameFromASiblingBranch() { - SealedHierarchy hierarchy = SealedTypes.hierarchyOf(Fixtures.Animal.class); + SealedHierarchy hierarchy = types.hierarchyOf(Fixtures.Animal.class); assertThat(hierarchy.resolve(Fixtures.Animal.class, "Bird")).isEqualTo(Fixtures.Bird.class); assertThat(hierarchy.resolve(Fixtures.Dog.class, "Bird")).isNull(); } @Test void refusesANameFromOutsideTheHierarchy() { - SealedHierarchy hierarchy = SealedTypes.hierarchyOf(Fixtures.Animal.class); + SealedHierarchy hierarchy = types.hierarchyOf(Fixtures.Animal.class); assertThat(hierarchy.resolve(Fixtures.Animal.class, "Rect")).isNull(); assertThat(hierarchy.resolve(Fixtures.Animal.class, "com.github.pjfanning.jackson.sealed.poly.Fixtures$Dog")).isNull(); @@ -164,16 +167,16 @@ void refusesANameFromOutsideTheHierarchy() { @Test void refusesToNameATypeOutsideTheHierarchy() { - assertThatThrownBy(() -> SealedTypes.hierarchyOf(Fixtures.Animal.class).nameOf(String.class)) + assertThatThrownBy(() -> types.hierarchyOf(Fixtures.Animal.class).nameOf(String.class)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("is not a permitted implementation"); } @Test void survivesTheCacheBeingCleared() { - SealedHierarchy before = SealedTypes.hierarchyOf(Fixtures.Animal.class); + SealedHierarchy before = types.hierarchyOf(Fixtures.Animal.class); SealedPolymorphismModule.clearCache(); - SealedHierarchy after = SealedTypes.hierarchyOf(Fixtures.Animal.class); + SealedHierarchy after = types.hierarchyOf(Fixtures.Animal.class); assertThat(after).isNotSameAs(before); assertThat(after.resolve(Fixtures.Animal.class, "Dog")).isEqualTo(Fixtures.Dog.class); } diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/EnumsUntouchedTest.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/EnumsUntouchedTest.java index 3fa68f5..425eb79 100644 --- a/src/test/java/com/github/pjfanning/jackson/sealed/poly/EnumsUntouchedTest.java +++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/EnumsUntouchedTest.java @@ -17,6 +17,9 @@ import com.github.pjfanning.jackson.sealed.poly.Fixtures.SignalHolder; import com.github.pjfanning.jackson.sealed.poly.Fixtures.Status; import com.github.pjfanning.jackson.sealed.poly.Fixtures.StatusHolder; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Beacon; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.BeaconHolder; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Colour; import org.junit.jupiter.api.Test; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.json.JsonMapper; @@ -31,7 +34,7 @@ class EnumsUntouchedTest { private final ObjectMapper withModule = JsonMapper.builder() - .addModule(new SealedPolymorphismModule()) + .addModule(new SealedPolymorphismModule().registerSealedInterfaceOrClass(Beacon.class)) .build(); private final ObjectMapper vanilla = JsonMapper.builder().build(); @@ -40,13 +43,20 @@ private void agreesWithPlainJackson(Object value) { } @Test - void writesAnEnumMemberOfAHierarchyAsPlainJacksonDoes() { + void writesAnEnumMemberOfAMarkedHierarchyAsPlainJacksonDoes() { agreesWithPlainJackson(new SignalHolder(Status.IDLE)); agreesWithPlainJackson(new StatusHolder(Status.BUSY)); agreesWithPlainJackson(Status.IDLE); assertThat(withModule.writeValueAsString(new SignalHolder(Status.IDLE))).isEqualTo("{\"signal\":\"IDLE\"}"); } + @Test + void writesAnEnumMemberOfARegisteredHierarchyAsPlainJacksonDoes() { + agreesWithPlainJackson(new BeaconHolder(Colour.RED)); + agreesWithPlainJackson(Colour.GREEN); + assertThat(withModule.writeValueAsString(new BeaconHolder(Colour.RED))).isEqualTo("{\"beacon\":\"RED\"}"); + } + /** * Constants with bodies compile to anonymous subclasses, and such an enum is implicitly sealed * and abstract - so it reaches every check this module makes about sealed types. diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/RegisteredTypeTest.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/RegisteredTypeTest.java new file mode 100644 index 0000000..cdc0650 --- /dev/null +++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/RegisteredTypeTest.java @@ -0,0 +1,230 @@ +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.UnmarkedFixtures.AjarHolder; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Ajar; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Beacon; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.BeaconHolder; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.BigCrate; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Bike; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Car; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Cargo; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Colour; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Crate; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Deck; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Garage; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Hold; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Loose; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Manifest; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Propped; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Pulse; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Scooter; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Shipment; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Stamped; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Tier1; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Tier2; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Tier3A; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.TierHolder; +import com.github.pjfanning.jackson.sealed.poly.UnmarkedFixtures.Vehicle; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Hierarchies opted in by registration rather than by extending the marker, for types whose source + * cannot be changed. A registered hierarchy is handled exactly as a marked one, and is held to the + * same requirement that it be sealed. + */ +class RegisteredTypeTest { + + private static ObjectMapper mapperFor(Class... roots) { + SealedPolymorphismModule module = new SealedPolymorphismModule(); + for (Class root : roots) { + module.registerSealedInterfaceOrClass(root); + } + return JsonMapper.builder().addModule(module).build(); + } + + private final ObjectMapper mapper = mapperFor(Vehicle.class, Cargo.class, Beacon.class, Crate.class); + + private T roundTrip(ObjectMapper m, T value, Class clazz) { + return m.readValue(m.writeValueAsString(value), clazz); + } + + @Test + void tagsARegisteredHierarchy() { + assertThat(mapper.writeValueAsString(new Garage("bay", new Car("abc")))) + .isEqualTo("{\"name\":\"bay\",\"vehicle\":{\"@type\":\"Car\",\"plate\":\"abc\"}}"); + assertThat(mapper.writeValueAsString(new Garage("bay", new Scooter()))) + .isEqualTo("{\"name\":\"bay\",\"vehicle\":{\"@type\":\"Scooter\"}}"); + } + + @Test + void roundTripsARegisteredHierarchy() { + assertThat(roundTrip(mapper, new Garage("bay", new Car("abc")), Garage.class)) + .isEqualTo(new Garage("bay", new Car("abc"))); + assertThat(roundTrip(mapper, new Garage("bay", new Bike(21)), Garage.class)) + .isEqualTo(new Garage("bay", new Bike(21))); + } + + @Test + void readsARegisteredBaseTypeAtTheTopLevel() { + assertThat(mapper.readValue("{\"@type\":\"Car\",\"plate\":\"abc\"}", Vehicle.class)) + .isEqualTo(new Car("abc")); + } + + /** The same types, through a module that was not told about them, must be untouched. */ + @Test + void leavesTheSameHierarchyAloneWhenItIsNotRegistered() { + ObjectMapper plain = JsonMapper.builder().addModule(new SealedPolymorphismModule()).build(); + assertThat(plain.writeValueAsString(new Car("abc"))).isEqualTo("{\"plate\":\"abc\"}"); + assertThat(plain.readValue("{\"plate\":\"abc\"}", Car.class)).isEqualTo(new Car("abc")); + } + + @Test + void appliesTheSameNamingRulesToARegisteredHierarchy() { + 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"))); + } + + /** Enums are left to Jackson in a registered hierarchy exactly as in a marked one. */ + @Test + void leavesEnumsOfARegisteredHierarchyToJackson() { + 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))); + } + + @Test + void dispatchesAtARegisteredConcreteRoot() { + 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"); + } + + /** Registering a type part way down a hierarchy makes that type the root. */ + @Test + void treatsARegisteredMidHierarchyTypeAsTheRoot() { + ObjectMapper m = mapperFor(Tier2.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))); + // Tier1 sits above the registered root, so it was never opted in + assertThat(Tier1.class.isAssignableFrom(Tier3A.class)).isTrue(); + } + + @Test + void handlesMarkedAndRegisteredHierarchiesInTheSameMapper() { + ObjectMapper m = mapperFor(Vehicle.class); + assertThat(m.writeValueAsString(new Fixtures.Owner("ann", new Fixtures.Dog("rex")))) + .isEqualTo("{\"name\":\"ann\",\"pet\":{\"@type\":\"Dog\",\"name\":\"rex\"}}"); + assertThat(m.writeValueAsString(new Garage("bay", new Car("abc")))) + .isEqualTo("{\"name\":\"bay\",\"vehicle\":{\"@type\":\"Car\",\"plate\":\"abc\"}}"); + } + + @Test + void refusesToRegisterATypeThatIsNotSealed() { + assertThatThrownBy(() -> new SealedPolymorphismModule().registerSealedInterfaceOrClass(Loose.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Loose") + .hasMessageContaining("is not sealed") + .hasMessageContaining("registration replaces"); + } + + @Test + void refusesToRegisterAnEnum() { + assertThatThrownBy(() -> new SealedPolymorphismModule().registerSealedInterfaceOrClass(Colour.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("is an enum") + .hasMessageContaining("Register the sealed interface it implements"); + } + + @Test + void refusesToRegisterNull() { + assertThatThrownBy(() -> new SealedPolymorphismModule().registerSealedInterfaceOrClass(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("null"); + } + + /** Registration does not relax the closed-hierarchy rule below the root. */ + @Test + void stillRefusesARegisteredHierarchyReopenedBelowTheRoot() { + ObjectMapper m = mapperFor(Ajar.class); + assertThatThrownBy(() -> m.writeValueAsString(new AjarHolder(new Propped(1)))) + .satisfies(error -> { + Throwable cause = error; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + assertThat(String.valueOf(cause.getMessage())) + .contains("Propped") + .contains("neither sealed nor final"); + }); + } + + @Test + void refusesToRegisterATypeThatCarriesJsonTypeInfo() { + assertThatThrownBy(() -> new SealedPolymorphismModule().registerSealedInterfaceOrClass(Stamped.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Stamped") + .hasMessageContaining("carries @JsonTypeInfo") + .hasMessageContaining("two type properties at once"); + } + + @Test + void exposesWhatWasRegistered() { + SealedPolymorphismModule module = new SealedPolymorphismModule() + .registerSealedInterfaceOrClass(Vehicle.class) + .registerSealedInterfaceOrClass(Cargo.class); + assertThat(module.registeredTypes()).containsExactly(Vehicle.class, Cargo.class); + assertThat(new SealedPolymorphismModule().registeredTypes()).isEmpty(); + } + + @Test + void ignoresARepeatedRegistration() { + SealedPolymorphismModule module = new SealedPolymorphismModule() + .registerSealedInterfaceOrClass(Vehicle.class) + .registerSealedInterfaceOrClass(Vehicle.class); + assertThat(module.registeredTypes()).containsExactly(Vehicle.class); + } + + /** Registering is cumulative - each call adds a hierarchy, it does not replace the last. */ + @Test + void handlesEveryHierarchyRegisteredOnOneModule() { + SealedPolymorphismModule module = new SealedPolymorphismModule() + .registerSealedInterfaceOrClass(Vehicle.class) + .registerSealedInterfaceOrClass(Cargo.class); + ObjectMapper m = JsonMapper.builder().addModule(module).build(); + assertThat(m.writeValueAsString(new Garage("bay", new Car("abc")))) + .isEqualTo("{\"name\":\"bay\",\"vehicle\":{\"@type\":\"Car\",\"plate\":\"abc\"}}"); + assertThat(m.writeValueAsString(new Manifest(new Hold.Item(1)))) + .isEqualTo("{\"cargo\":{\"@type\":\"Hold$Item\",\"n\":1}}"); + // and the marker still works alongside them + assertThat(m.writeValueAsString(new Fixtures.Owner("ann", new Fixtures.Dog("rex")))) + .isEqualTo("{\"name\":\"ann\",\"pet\":{\"@type\":\"Dog\",\"name\":\"rex\"}}"); + } + + /** Jackson drops a module whose registration id it has already seen. */ + @Test + void distinguishesModulesByWhatTheyRegister() { + assertThat(new SealedPolymorphismModule().registerSealedInterfaceOrClass(Vehicle.class).getRegistrationId()) + .isNotEqualTo(new SealedPolymorphismModule() + .registerSealedInterfaceOrClass(Cargo.class).getRegistrationId()); + assertThat(new SealedPolymorphismModule().getRegistrationId()) + .isNotEqualTo(new SealedPolymorphismModule() + .registerSealedInterfaceOrClass(Vehicle.class).getRegistrationId()); + } +} diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/UnmarkedFixtures.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/UnmarkedFixtures.java new file mode 100644 index 0000000..13261a6 --- /dev/null +++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/UnmarkedFixtures.java @@ -0,0 +1,150 @@ +package com.github.pjfanning.jackson.sealed.poly; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Sealed hierarchies that do not extend {@code SealedPolymorphismSupport}, standing in for + * types whose source cannot be changed - from a library, or generated. They opt in by being + * registered with the module instead. + * + *

Nothing here imports the module, which is the point: these are ordinary sealed types. + */ +public final class UnmarkedFixtures { + + private UnmarkedFixtures() { + } + + public sealed interface Vehicle permits Car, Bike, Scooter { + } + + public record Car(String plate) implements Vehicle { + } + + public record Bike(int gears) implements Vehicle { + } + + public record Scooter() implements Vehicle { + } + + public record Garage(String name, Vehicle vehicle) { + } + + // implementations grouped into classes that do not enclose the root, to check the naming rules + // apply to a registered 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 type part way down can be registered as the root + 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) { + } + + // an enum member of an unmarked hierarchy + 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) { + } + + // 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) { + } + + // sealed, but Jackson's own annotations already handle it - registering it must be refused + @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 { + } + + // not sealed at all - registering it must be refused + public interface Loose { + } + + public record LooseImpl(int x) implements Loose { + } + + // sealed, but reopened below - registering is fine, using it is not + 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) { + } +}