Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A Java port of the `SealedPolymorphismSupport` added to jackson-module-scala in
using the same `@type` property and the same name-derivation rules. Enums are the one place the two
diverge: Scala tags a `case object`, whereas this module leaves a Java enum to Jackson as a string.

> **Status: early.** Covered by 76 tests, including ports of the Scala module's
> **Status: early.** Covered by 90 tests, including ports of the Scala module's
> `SealedPolymorphismSpec` and `NestedPolymorphismSpec`, so the examples below are verified output.
> Snapshots are published, but there is no release yet and the API may still change.

Expand Down Expand Up @@ -74,8 +74,45 @@ mapper.readValue("{\"@type\":\"Dog\",\"name\":\"rex\"}", Animal.class);
// Dog[name=rex]
```

The module only ever looks at types carrying the marker, so registering it has no effect on
anything else your application serializes.
The module only ever looks at types that have opted in — through the marker, or through a
[mix-in](#hierarchies-you-cannot-change) — so adding it has no effect on anything else your
application serializes.

## Hierarchies you cannot change

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

```java
// yours, in your own package
public interface AnimalMixIn extends SealedPolymorphismSupport {}

ObjectMapper mapper = JsonMapper.builder()
.addModule(new SealedPolymorphismModule())
.addMixIn(Animal.class, AnimalMixIn.class)
.build();
```

The mix-in carries the marker and nothing else. It does not — and cannot — extend the hierarchy it
is mixed into: a sealed type's `permits` clause names its subtypes, and someone who cannot change
those classes cannot add themselves to it. Jackson never requires a mix-in to be a subtype of what
it is mixed into; it only harvests annotations and supertypes from it.

Mix it into the **root**; its implementations follow from the `permits` clause. A mix-in does not
change the type on the JVM, so the marker is not inherited by the implementations the way it would
be if the base extended it — only the root is opted in, and the module reads that from the mapper's
configuration rather than from the classes.

A mixed-in hierarchy is handled exactly as a marked one, and held to the same requirements: it must
be sealed, a `non-sealed` member is still reported, and enums are still left to Jackson. Mixing into
a type part way down a hierarchy is allowed and makes that type the root.

Two mix-ins that are not opt-ins:

| Mix-in | Result |
| --- | --- |
| does not extend `SealedPolymorphismSupport` | opts nothing in — the hierarchy stays untouched |
| carries `@JsonTypeInfo` as well as the marker | the module stands down, as it does for the annotation on the class |

## How names are derived

Expand Down Expand Up @@ -208,12 +245,13 @@ performance but not behaviour.

## Tests

76 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`:
90 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`:

| Test | Covers |
| --- | --- |
| `poly/SealedPolymorphismTest` | Ported from the Scala `SealedPolymorphismSpec` |
| `poly/EnumsUntouchedTest` | That enums serialize identically with and without this module |
| `poly/MixInTest` | Hierarchies opted in by a Jackson mix-in rather than by the marker |
| `poly/NestedPolymorphismTest` | Ported from the Scala `NestedPolymorphismSpec` — a polymorphic value holding a polymorphic value |
| `poly/InvalidHierarchyTest` | The three ways a hierarchy can fail to be closed — not sealed, reopened by a `non-sealed` member, clashing derived names — on both the read and the write path, plus that a marked enum is ignored |
| `SealedTypesTest` | The name derivation itself, and resolution |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,25 @@ final class SealedPolymorphicDeserializer extends StdDeserializer<Object> {
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ final class SealedPolymorphismDeserializerModifier extends ValueDeserializerModi
public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanDescription.Supplier beanDescRef,
BeanDeserializerBuilder builder) {
Class<?> rawClass = beanDescRef.getBeanClass();
if (!SealedTypes.isMarked(rawClass)) {
if (!SealedTypes.isMarked(config, rawClass)) {
return builder;
}
SealedTypes.checkNoConflictingJsonTypeInfo(rawClass);
if (SealedTypes.isSupported(rawClass) && SealedTypes.isConcrete(rawClass)) {
SealedTypes.checkNoConflictingJsonTypeInfo(config, rawClass);
if (SealedTypes.isSupported(config, rawClass) && SealedTypes.isConcrete(rawClass)) {
builder.addIgnorable(SealedTypes.TYPE_PROPERTY_NAME);
}
return builder;
Expand All @@ -33,12 +33,12 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD
public ValueDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription.Supplier beanDescRef,
ValueDeserializer<?> deserializer) {
Class<?> rawClass = beanDescRef.getBeanClass();
if (!SealedTypes.needsSubtypeDispatch(rawClass)) {
if (!SealedTypes.needsSubtypeDispatch(config, rawClass)) {
return deserializer;
}
@SuppressWarnings("unchecked")
ValueDeserializer<Object> delegate = (ValueDeserializer<Object>) deserializer;
return new TaggedBeanDeserializer(rawClass, delegate);
return new TaggedBeanDeserializer(rawClass, SealedTypes.hierarchyOf(config, rawClass), delegate);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ final class SealedPolymorphismDeserializers extends Deserializers.Base {
public ValueDeserializer<?> findBeanDeserializer(JavaType type, DeserializationConfig config,
BeanDescription.Supplier beanDescRef) {
Class<?> rawClass = type.getRawClass();
return hasDeserializerFor(config, rawClass) ? new SealedPolymorphicDeserializer(rawClass) : null;
return hasDeserializerFor(config, rawClass)
? new SealedPolymorphicDeserializer(rawClass, SealedTypes.hierarchyOf(config, rawClass))
: null;
}

@Override
public boolean hasDeserializerFor(DeserializationConfig config, Class<?> valueType) {
return SealedTypes.isBaseType(valueType);
return SealedTypes.isBaseType(config, valueType);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ final class SealedPolymorphismSerializerModifier extends ValueSerializerModifier
public ValueSerializer<?> modifySerializer(SerializationConfig config, BeanDescription.Supplier beanDescRef,
ValueSerializer<?> serializer) {
Class<?> rawClass = beanDescRef.getBeanClass();
if (!SealedTypes.isMarked(rawClass)) {
if (!SealedTypes.isMarked(config, rawClass)) {
return serializer;
}
SealedTypes.checkNoConflictingJsonTypeInfo(rawClass);
SealedTypes.checkNoConflictingJsonTypeInfo(config, rawClass);
// a base type is never written directly - only the implementation dispatched to at runtime
if (!SealedTypes.isSupported(rawClass) || !SealedTypes.isConcrete(rawClass)) {
if (!SealedTypes.isSupported(config, rawClass) || !SealedTypes.isConcrete(rawClass)) {
return serializer;
}
@SuppressWarnings("unchecked")
ValueSerializer<Object> delegate = (ValueSerializer<Object>) serializer;
return new TypeTaggedSerializer(SealedTypes.hierarchyOf(rawClass).nameOf(rawClass), delegate);
return new TypeTaggedSerializer(SealedTypes.hierarchyOf(config, rawClass).nameOf(rawClass), delegate);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@
* out as JSON that could not be read back. Records are closed by construction and need no modifier
* of their own.
*
* <h2>Hierarchies you cannot change</h2>
*
* <p>Extending this interface means editing the base type. Where that is not possible - a hierarchy
* from a library, or generated code - register a Jackson mix-in that extends it instead:
*
* <pre>{@code
* public interface AnimalMixIn extends SealedPolymorphismSupport {}
*
* JsonMapper.builder()
* .addModule(new SealedPolymorphismModule())
* .addMixIn(Animal.class, AnimalMixIn.class)
* .build();
* }</pre>
*
* <p>The mix-in carries the marker and nothing else. It does not - and cannot - extend the hierarchy
* it is mixed into: a sealed type's {@code permits} clause names its subtypes, and someone who
* cannot change those classes cannot add themselves to it. Jackson never requires a mix-in to be a
* subtype of what it is mixed into.
*
* <p>Mix it into the <em>root</em>; its implementations follow from the {@code permits} clause. A
* mix-in does not change the type on the JVM, so the marker is not inherited by the implementations
* the way it would be if the base extended it - only the root is opted in, and the module reads that
* from the mapper's configuration. A hierarchy opted in this way is held to exactly the same
* requirements as one that extends the marker directly.
*
* <h2>Enums are left to Jackson</h2>
*
* <p>An enum permitted by the root is not handled here. Jackson writes an enum as a string and keeps
Expand Down
Loading