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
41 changes: 27 additions & 14 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, so a value written by one is
readable by the other.

> **Status: early.** Covered by 70 tests, including ports of the Scala module's
> **Status: early.** Covered by 76 tests, including ports of the Scala module's
> `SealedPolymorphismSpec` and `NestedPolymorphismSpec`, so the examples below are verified output.
> Not published anywhere yet, and the API may still change.

Expand Down Expand Up @@ -117,23 +117,33 @@ This is the main thing the Java version does better than the Scala one. scalac l
`sealed` on the JVM, so jackson-module-scala has to rebuild candidate class names from where the
base is declared and filter them by subtype relationship.

## Enum members
## Enums are left to Jackson

An enum in a sealed hierarchy is the closest Java has to a set of Scala `case object`s. Because the
enum class holds several values, each *constant* is named individually:
This module does not touch enums, even ones permitted by a hierarchy it handles. Jackson writes an
enum as a string, and it keeps doing so — in every position, including as a `Map` key, and including
an enum with a custom `@JsonValue` representation or with constant bodies.

```java
public sealed interface Signal extends SealedPolymorphismSupport permits Data, Status {}

public record Data(int value) implements Signal {}
public enum Status implements Signal { IDLE, BUSY }

// {"@type":"Data","value":1}
// {"@type":"Status$IDLE"}
// {"signal":{"@type":"Data","value":1}} the record is tagged
// {"signal":"IDLE"} the enum is not
```

Only value serializers are replaced — an enum used as a `Map` key keeps Jackson's ordinary key
handling, since a tagged object cannot be a JSON property name.
An enum therefore has no `@type` name, which has one consequence worth knowing: **a value of an
enum member cannot be read back through the hierarchy's base type.** A string is not something the
base type can dispatch on, so reading `{"signal":"IDLE"}` as a `Signal` fails, and says why. Writing
works, and reading works wherever the property is declared as the enum type itself.

If you need a hierarchy member that round-trips through the base type and carries no state, use a
record with no components — `record Unknown() implements Animal {}` writes as `{"@type":"Unknown"}`
and reads straight back.

Putting the marker on an enum is not an error, it just has no effect: the enum is written as a
string either way.

## Concrete sealed roots

Expand Down Expand Up @@ -165,9 +175,10 @@ that could not be read back:
| `sealed class X implements SealedPolymorphismSupport` | supported — a value and a base |
| `interface X extends SealedPolymorphismSupport` | error: not sealed |
| `non-sealed class X implements Base` | error: reopens the hierarchy |
| `enum X implements SealedPolymorphismSupport` | error: mark the sealed interface it implements |
| `enum X implements SealedPolymorphismSupport` | ignored — enums are always Jackson's to write |

Records and enum constants are closed by construction and need no modifier of their own.
Records are closed by construction and need no modifier of their own. An enum permitted by the root
is skipped rather than checked, since this module does not handle it either way.

## Working alongside `@JsonTypeInfo`

Expand All @@ -194,11 +205,12 @@ performance but not behaviour.

## Tests

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

| Test | Covers |
| --- | --- |
| `poly/SealedPolymorphismTest` | Ported from the Scala `SealedPolymorphismSpec`, plus enum members |
| `poly/SealedPolymorphismTest` | Ported from the Scala `SealedPolymorphismSpec` |
| `poly/EnumsUntouchedTest` | That enums serialize identically with and without this module |
| `poly/NestedPolymorphismTest` | Ported from the Scala `NestedPolymorphismSpec` — a polymorphic value holding a polymorphic value |
| `poly/InvalidHierarchyTest` | The four ways a hierarchy can fail to be closed, on both the read and the write path |
| `SealedTypesTest` | The name derivation itself, and resolution |
Expand All @@ -209,8 +221,9 @@ performance but not behaviour.
endpoint, which are deliberately not wired up yet.
- A Jackson 2.x build. All Jackson API contact is confined to the serializer and deserializer
classes, so the reflection core would port unchanged.
- Polymorphic values as `Map` keys. An enum key keeps Jackson's ordinary key handling; a tagged
object cannot be a JSON property name, so a marked hierarchy is not usable as a key type.
- Polymorphic values as `Map` keys. A tagged object cannot be a JSON property name, so a handled
hierarchy is not usable as a key type. Enum keys are unaffected, being Jackson's to write.
- Reading an enum member back through the hierarchy's base type — see above.

## License

Expand Down
116 changes: 62 additions & 54 deletions src/main/java/com/github/pjfanning/jackson/sealed/SealedHierarchy.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@

import java.lang.reflect.Modifier;
import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Deque;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import com.fasterxml.jackson.annotation.JsonTypeInfo;

/**
* The implementations of one marked hierarchy, and the {@code @type} name each is written under.
* The implementations of one hierarchy, and the {@code @type} name each is written under.
*
* <p>Built once per hierarchy root by walking the {@code PermittedSubclasses} attribute that
* {@code javac} records for every {@code sealed} type. The result is an exact, closed table: a name
Expand All @@ -22,32 +26,32 @@
*
* <p>Building the table is also where the hierarchy is checked to be closed, so a type that could
* not be read back is reported before anything is written.
*
* <p>Enum members are left out. Jackson writes an enum as a string, and this module does not take
* that over, so an enum permitted by the root carries no {@code @type} name of its own.
*/
final class SealedHierarchy {

private final Class<?> root;
/** True when the root carries {@code @JsonTypeInfo}, so Jackson's own handling owns it. */
private final boolean jacksonOwned;
private final Map<String, Subtype> byName;
private final Map<String, Class<?>> byName;
private final Map<Class<?>, String> namesByClass;
/** Permitted enums, kept only so that a value of one can be explained when it cannot be read. */
private final Set<Class<?>> enumMembers;

private SealedHierarchy(Class<?> root, boolean jacksonOwned,
Map<String, Subtype> byName, Map<Class<?>, String> namesByClass) {
private SealedHierarchy(Class<?> root, boolean jacksonOwned, Map<String, Class<?>> byName,
Map<Class<?>, String> namesByClass, Set<Class<?>> enumMembers) {
this.root = root;
this.jacksonOwned = jacksonOwned;
this.byName = byName;
this.namesByClass = namesByClass;
this.enumMembers = enumMembers;
}

static SealedHierarchy of(Class<?> root) {
if (root.getAnnotation(JsonTypeInfo.class) != null) {
return new SealedHierarchy(root, true, Map.of(), Map.of());
}
if (SealedTypes.enumClassOf(root) != null) {
throw new IllegalArgumentException(root.getName() + " is an enum marked with "
+ SealedPolymorphismSupport.class.getSimpleName() + ". An enum is not a hierarchy of its own; "
+ "mark the sealed interface it implements instead, and its constants are named "
+ "individually within that hierarchy.");
return new SealedHierarchy(root, true, Map.of(), Map.of(), Set.of());
}
if (!root.isSealed()) {
throw new IllegalArgumentException(root.getName() + " is marked with "
Expand All @@ -56,65 +60,59 @@ static SealedHierarchy of(Class<?> root) {
+ "its permitted subtypes as `final` or `sealed` in turn.");
}

Map<String, Subtype> byName = new HashMap<>();
Map<String, Class<?>> byName = new HashMap<>();
Map<Class<?>, String> namesByClass = new HashMap<>();
collect(root, root, byName, namesByClass, new HashSet<>());
return new SealedHierarchy(root, false, Map.copyOf(byName), Map.copyOf(namesByClass));
Set<Class<?>> enumMembers = new LinkedHashSet<>();
collect(root, byName, namesByClass, enumMembers);
// Set.copyOf does not keep insertion order, and this set is rendered into an error message
return new SealedHierarchy(root, false, Map.copyOf(byName), Map.copyOf(namesByClass),
Collections.unmodifiableSet(enumMembers));
}

private static void collect(Class<?> root, Class<?> current, Map<String, Subtype> byName,
Map<Class<?>, String> namesByClass, Set<Class<?>> seen) {
private static void collect(Class<?> root, Map<String, Class<?>> byName, Map<Class<?>, String> namesByClass,
Set<Class<?>> enumMembers) {
Set<Class<?>> seen = new HashSet<>();
Deque<Class<?>> queue = new ArrayDeque<>();
queue.add(current);
queue.add(root);
while (!queue.isEmpty()) {
Class<?> clazz = queue.poll();
if (!seen.add(clazz)) {
continue;
}
// an enum is Jackson's to write, as a string - it takes no name here, and its constant
// bodies are not part of this hierarchy either
Class<?> enumClass = SealedTypes.enumClassOf(clazz);
if (enumClass != null) {
// an enum is closed by construction, and each constant is a value of the hierarchy in
// its own right - the Java counterpart of the Scala module's `case object`
String prefix = SealedTypes.typeNameFor(root, enumClass);
for (Object constant : enumClass.getEnumConstants()) {
Enum<?> value = (Enum<?>) constant;
register(root, byName, prefix + '$' + value.name(), Subtype.ofConstant(value), value.getClass());
}
enumMembers.add(enumClass);
continue;
}

int modifiers = clazz.getModifiers();
boolean sealed = clazz.isSealed();
if (!sealed && !Modifier.isFinal(modifiers)) {
throw new IllegalArgumentException(clazz.getName() + " belongs to the "
+ SealedPolymorphismSupport.class.getSimpleName() + " hierarchy rooted at " + root.getName()
+ ", but is neither sealed nor final. A `non-sealed` type reopens the hierarchy, so its "
+ "subclasses could not be resolved back from a " + SealedTypes.TYPE_PROPERTY_NAME
+ " name; declare " + clazz.getSimpleName() + " as `final` or `sealed`.");
if (!sealed && !Modifier.isFinal(clazz.getModifiers())) {
throw new IllegalArgumentException(clazz.getName() + " belongs to the sealed hierarchy rooted at "
+ root.getName() + ", but is neither sealed nor final. A `non-sealed` type reopens the "
+ "hierarchy, so its subclasses could not be resolved back from a "
+ SealedTypes.TYPE_PROPERTY_NAME + " name; declare " + clazz.getSimpleName()
+ " as `final` or `sealed`.");
}
// an interface or abstract class is only ever dispatched through, so carries no name of its own
if (SealedTypes.isConcrete(clazz)) {
String name = SealedTypes.typeNameFor(root, clazz);
register(root, byName, name, Subtype.ofClass(clazz), clazz);
Class<?> existing = byName.putIfAbsent(name, clazz);
if (existing != null && existing != clazz) {
throw new IllegalArgumentException(clazz.getName() + " is written as "
+ SealedTypes.TYPE_PROPERTY_NAME + " '" + name + "', but that name already belongs to "
+ existing.getName() + " in the hierarchy rooted at " + root.getName()
+ ". Rename one of them so that the two derive different names.");
}
namesByClass.put(clazz, name);
}
if (sealed) {
queue.addAll(java.util.Arrays.asList(clazz.getPermittedSubclasses()));
queue.addAll(Arrays.asList(clazz.getPermittedSubclasses()));
}
}
}

private static void register(Class<?> root, Map<String, Subtype> byName, String name, Subtype subtype,
Class<?> declaring) {
Subtype existing = byName.putIfAbsent(name, subtype);
if (existing != null && !existing.equals(subtype)) {
throw new IllegalArgumentException(declaring.getName() + " is written as "
+ SealedTypes.TYPE_PROPERTY_NAME + " '" + name + "', but that name already belongs to "
+ existing.type().getName() + " in the hierarchy rooted at " + root.getName()
+ ". Rename one of them so that the two derive different names.");
}
}

Class<?> root() {
return root;
}
Expand All @@ -132,26 +130,36 @@ boolean isJacksonOwned() {
* {@code baseClass} here, so a name from a sibling branch does not resolve into a property that
* could not hold it.
*/
Subtype resolve(Class<?> baseClass, String typeName) {
Class<?> resolve(Class<?> baseClass, String typeName) {
if (typeName == null) {
return null;
}
Subtype subtype = byName.get(typeName);
return (subtype != null && baseClass.isAssignableFrom(subtype.type())) ? subtype : null;
Class<?> subtype = byName.get(typeName);
return (subtype != null && baseClass.isAssignableFrom(subtype)) ? subtype : null;
}

/**
* Explains that a value read at this hierarchy's base was not an object, when the reason is that
* the hierarchy permits an enum - which Jackson writes as a string, and which therefore cannot
* be dispatched on. Returns {@code null} when that is not the explanation.
*/
String enumMemberHint() {
if (enumMembers.isEmpty()) {
return null;
}
return " The hierarchy permits " + enumMembers.stream().map(Class::getSimpleName)
.collect(Collectors.joining(", ")) + ", which Jackson writes as a string rather than a tagged "
+ "object, so a value of that type cannot be read back through " + root.getSimpleName()
+ ". Declare the property as the enum type itself, or replace the enum with final classes.";
}

/** The {@code @type} name for a concrete implementation. */
String nameOf(Class<?> clazz) {
String name = namesByClass.get(clazz);
if (name == null) {
throw new IllegalArgumentException(clazz.getName() + " is not a permitted implementation of the "
+ SealedPolymorphismSupport.class.getSimpleName() + " hierarchy rooted at " + root.getName() + ".");
throw new IllegalArgumentException(clazz.getName() + " is not a permitted implementation of the sealed "
+ "hierarchy rooted at " + root.getName() + ".");
}
return name;
}

/** The {@code @type} name of an enum constant of this hierarchy. */
String nameOfConstant(Enum<?> constant) {
return SealedTypes.typeNameFor(root, constant.getDeclaringClass()) + '$' + constant.name();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,15 @@ final class SealedPolymorphicDeserializer extends StdDeserializer<Object> {
@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt) {
if (p.currentToken() != JsonToken.START_OBJECT) {
return ctxt.reportInputMismatch(baseClass, "Expected a JSON object with a %s property to create %s",
SealedTypes.TYPE_PROPERTY_NAME, baseClass.getName());
String hint = SealedTypes.hierarchyOf(baseClass).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);
Subtype subtype = SealedTypes.hierarchyOf(baseClass).resolve(baseClass, tagged.typeName());
Class<?> subtype = SealedTypes.hierarchyOf(baseClass).resolve(baseClass, tagged.typeName());
if (subtype == null) {
return TaggedObject.unresolved(ctxt, baseClass, tagged.typeName());
}
if (subtype.singleton() != null) {
return subtype.singleton();
}
return ctxt.readValue(tagged.parser(), subtype.type());
return ctxt.readValue(tagged.parser(), subtype);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import tools.jackson.databind.BeanDescription;
import tools.jackson.databind.DeserializationConfig;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.ValueDeserializer;
import tools.jackson.databind.deser.BeanDeserializerBuilder;
import tools.jackson.databind.deser.ValueDeserializerModifier;
Expand Down Expand Up @@ -42,16 +41,4 @@ public ValueDeserializer<?> modifyDeserializer(DeserializationConfig config, Bea
return new TaggedBeanDeserializer(rawClass, delegate);
}

@Override
public ValueDeserializer<?> modifyEnumDeserializer(DeserializationConfig config, JavaType valueType,
BeanDescription.Supplier beanDescRef,
ValueDeserializer<?> deserializer) {
Class<?> rawClass = valueType.getRawClass();
if (!SealedTypes.isMarked(rawClass) || !SealedTypes.isSupported(rawClass)) {
return deserializer;
}
@SuppressWarnings("unchecked")
ValueDeserializer<Object> delegate = (ValueDeserializer<Object>) deserializer;
return new TaggedEnumDeserializer(rawClass, delegate);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.github.pjfanning.jackson.sealed;

import tools.jackson.databind.BeanDescription;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.SerializationConfig;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.ser.ValueSerializerModifier;
Expand All @@ -26,27 +25,9 @@ public ValueSerializer<?> modifySerializer(SerializationConfig config, BeanDescr
if (!SealedTypes.isSupported(rawClass) || !SealedTypes.isConcrete(rawClass)) {
return serializer;
}
// an enum reaches this path as well as modifyEnumSerializer, and is named per constant
if (SealedTypes.enumClassOf(rawClass) != null) {
return new TypeTaggedEnumSerializer(SealedTypes.hierarchyOf(rawClass));
}
@SuppressWarnings("unchecked")
ValueSerializer<Object> delegate = (ValueSerializer<Object>) serializer;
return new TypeTaggedSerializer(SealedTypes.hierarchyOf(rawClass).nameOf(rawClass), delegate);
}

@Override
public ValueSerializer<?> modifyEnumSerializer(SerializationConfig config, JavaType valueType,
BeanDescription.Supplier beanDescRef,
ValueSerializer<?> serializer) {
Class<?> rawClass = valueType.getRawClass();
if (!SealedTypes.isMarked(rawClass)) {
return serializer;
}
SealedTypes.checkNoConflictingJsonTypeInfo(rawClass);
if (!SealedTypes.isSupported(rawClass)) {
return serializer;
}
return new TypeTaggedEnumSerializer(SealedTypes.hierarchyOf(rawClass));
}
}
Loading