diff --git a/README.md b/README.md
index ac5464f..49d5896 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, 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.
@@ -117,10 +117,11 @@ 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 {}
@@ -128,12 +129,21 @@ public sealed interface Signal extends SealedPolymorphismSupport permits Data, S
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
@@ -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`
@@ -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 |
@@ -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
diff --git a/src/main/java/com/github/pjfanning/jackson/sealed/SealedHierarchy.java b/src/main/java/com/github/pjfanning/jackson/sealed/SealedHierarchy.java
index 431db08..0f54b0f 100644
--- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedHierarchy.java
+++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedHierarchy.java
@@ -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.
*
*
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
@@ -22,32 +26,32 @@
*
*
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.
+ *
+ *
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 byName;
+ private final Map> byName;
private final Map, String> namesByClass;
+ /** Permitted enums, kept only so that a value of one can be explained when it cannot be read. */
+ private final Set> enumMembers;
- private SealedHierarchy(Class> root, boolean jacksonOwned,
- Map byName, Map, String> namesByClass) {
+ private SealedHierarchy(Class> root, boolean jacksonOwned, Map> byName,
+ Map, String> namesByClass, Set> 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 "
@@ -56,65 +60,59 @@ static SealedHierarchy of(Class> root) {
+ "its permitted subtypes as `final` or `sealed` in turn.");
}
- Map byName = new HashMap<>();
+ Map> byName = new HashMap<>();
Map, String> namesByClass = new HashMap<>();
- collect(root, root, byName, namesByClass, new HashSet<>());
- return new SealedHierarchy(root, false, Map.copyOf(byName), Map.copyOf(namesByClass));
+ Set> 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 byName,
- Map, String> namesByClass, Set> seen) {
+ private static void collect(Class> root, Map> byName, Map, String> namesByClass,
+ Set> enumMembers) {
+ Set> seen = new HashSet<>();
Deque> 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 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;
}
@@ -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();
- }
}
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 c5d22ee..b593601 100644
--- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphicDeserializer.java
+++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphicDeserializer.java
@@ -23,17 +23,15 @@ final class SealedPolymorphicDeserializer extends StdDeserializer