From a3422744ce5237d4a0fca7f877b7ed6e15a51e40 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sun, 23 Aug 2026 20:19:16 +0100 Subject: [PATCH] Support registering sealed types without the marker interface Opting in meant extending SealedPolymorphismSupport, which requires editing the base type. That is not possible for a hierarchy from a library or from generated code, so a root can be registered instead: new SealedPolymorphismModule() .registerSealedInterfaceOrClass(Animal.class) .registerSealedInterfaceOrClass(Shape.class) registerSealedInterfaceOrClass takes one type and is callable 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 identically to a marked one - same names, same reading, same conflict rules - and is held to the same requirement that it be sealed. Registering null, a type that is not sealed, an enum, or one carrying @JsonTypeInfo is refused by the register call rather than later when Jackson first meets the type. @JsonTypeInfo is refused because it already tells Jackson how to write and read the hierarchy, so registering as well would ask for two type properties at once. Nothing is relaxed below the root either: a non-sealed member is still reported. Register the root; implementations follow from its permits clause. Registering part way down a hierarchy is allowed and makes that type the root, so names are derived relative to it. Which types are handled now depends on how the module was configured, so the opt-in check and root resolution moved from statics to per-module state, and registering discards the cache of which hierarchy a type belongs to, since a type that resolved to nothing may now resolve to a root. A hierarchy's name table is derived from the class files alone and is identical however the hierarchy opted in, so that stays globally cached, and the deserializers take the resolved hierarchy rather than looking it up. getRegistrationId includes what was registered, since Jackson drops a module whose registration id it has already seen and two modules registering different types are not the same module. Rebased onto main after the enum work landed separately in #2. Enum handling here is identical to main's, including that a marked enum is ignored rather than reported. 19 new tests; 95 total. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4 --- README.md | 43 ++- .../sealed/SealedPolymorphicDeserializer.java | 10 +- ...ealedPolymorphismDeserializerModifier.java | 16 +- .../SealedPolymorphismDeserializers.java | 12 +- .../sealed/SealedPolymorphismModule.java | 87 +++++- .../SealedPolymorphismSerializerModifier.java | 14 +- .../sealed/SealedPolymorphismSupport.java | 18 +- .../pjfanning/jackson/sealed/SealedTypes.java | 259 ++++++++++++------ .../sealed/TaggedBeanDeserializer.java | 10 +- .../jackson/sealed/TaggedObject.java | 6 +- .../jackson/sealed/SealedTypesTest.java | 63 +++-- .../sealed/poly/EnumsUntouchedTest.java | 14 +- .../sealed/poly/RegisteredTypeTest.java | 230 ++++++++++++++++ .../jackson/sealed/poly/UnmarkedFixtures.java | 150 ++++++++++ 14 files changed, 777 insertions(+), 155 deletions(-) create mode 100644 src/test/java/com/github/pjfanning/jackson/sealed/poly/RegisteredTypeTest.java create mode 100644 src/test/java/com/github/pjfanning/jackson/sealed/poly/UnmarkedFixtures.java 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) { + } +}