diff --git a/README.md b/README.md
index 9f09426..da426c5 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ style rather than a missing capability. Extend `SealedPolymorphismSupport` from
hierarchy and every implementation gains a `@type` property, with no Jackson annotations on your
types at all.
-> **Status: early.** Covered by 90 tests, so the examples below are verified output. Snapshots are
+> **Status: early.** Covered by 93 tests, so the examples below are verified output. Snapshots are
> published, but there is no release yet and the API may still change.
## Jackson can already do this
@@ -37,8 +37,8 @@ than this module. What follows is what you get by using this module instead.
That last row is the one substantive difference. Given `Boxed.Same` and `Nested.Same` in one
hierarchy, `Id.SIMPLE_NAME` writes `{"type":"Same"}` for both — and reads both back as
-`Nested.Same`, so a `Boxed.Same` silently becomes something else. This module derives `Boxed$Same`
-and `Nested$Same`, and refuses at startup if two implementations would still collide.
+`Nested.Same`, so a `Boxed.Same` silently becomes something else. This module derives `Boxed.Same`
+and `Nested.Same`, and refuses at startup if two implementations would still collide.
## Requirements
@@ -163,10 +163,15 @@ implementation called the same thing:
```java
public sealed interface Dup extends SealedPolymorphismSupport permits Boxed.Same, Nested.Same {}
-class Boxed { record Same(int v) implements Dup {} } // {"@type":"Boxed$Same","v":1}
-class Nested { record Same(String v) implements Dup {} } // {"@type":"Nested$Same","v":"x"}
+class Boxed { record Same(int v) implements Dup {} } // {"@type":"Boxed.Same","v":1}
+class Nested { record Same(String v) implements Dup {} } // {"@type":"Nested.Same","v":"x"}
```
+The dot separates a nested implementation from the class enclosing it, as jackson-databind does; the
+JVM writes that boundary as a `$`. Only a real nesting boundary becomes a dot, so a `$` that is part
+of a class's own name is left alone. A dot in a `@type` can never address a package either: names
+are resolved against the hierarchy's own table, never handed to `Class.forName`.
+
If two implementations would derive the same name, that is reported as a configuration error rather
than producing JSON that could not be read back unambiguously.
@@ -269,7 +274,7 @@ performance but not behaviour.
## Tests
-90 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`:
+93 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`:
| Test | Covers |
| --- | --- |
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 fa03d65..7c28e1d 100644
--- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java
+++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedPolymorphismSupport.java
@@ -27,8 +27,8 @@
*
{@code
* public sealed interface Dup extends SealedPolymorphismSupport permits Boxed.Same, Nested.Same {}
*
- * class Boxed { record Same(int v) implements Dup {} } // {"@type":"Boxed$Same","v":1}
- * class Nested { record Same(String v) implements Dup {} } // {"@type":"Nested$Same","v":"x"}
+ * class Boxed { record Same(int v) implements Dup {} } // {"@type":"Boxed.Same","v":1}
+ * class Nested { record Same(String v) implements Dup {} } // {"@type":"Nested.Same","v":"x"}
* }
*
* Unlike Scala, {@code javac} records a sealed hierarchy in the class file, so the set of
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 6a6fcca..0981a94 100644
--- a/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java
+++ b/src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java
@@ -156,18 +156,46 @@ static void checkNoConflictingJsonTypeInfo(MixInResolver mixIns, Class> clazz)
*
*
An implementation declared beside the root, or nested inside the root itself, keeps its
* simple name. One nested inside some other class keeps that class in its name -
- * {@code Boxed$Same} rather than {@code Same} - so that two classes can each hold an
- * implementation of the same name.
+ * {@code Boxed.Same} rather than {@code Same} - so that two classes can each hold an
+ * implementation of the same name. The dot matches how jackson-databind separates a name from
+ * what encloses it; the JVM writes that boundary as a {@code $}.
*/
static String typeNameFor(Class> root, Class> clazz) {
String name = clazz.getName();
// prefixes run longest to shortest, so the first match is the most specific
+ int start = -1;
for (String prefix : prefixesFor(root.getName())) {
if (name.startsWith(prefix)) {
- return name.substring(prefix.length());
+ start = prefix.length();
+ break;
}
}
- return name.substring(name.lastIndexOf('.') + 1);
+ if (start < 0) {
+ start = name.lastIndexOf('.') + 1;
+ }
+ char[] derived = name.substring(start).toCharArray();
+ for (int boundary : nestingBoundaries(clazz)) {
+ if (boundary >= start) {
+ derived[boundary - start] = '.';
+ }
+ }
+ return new String(derived);
+ }
+
+ /**
+ * Where in a class name a {@code $} separates a class from the one enclosing it.
+ *
+ *
Only those are nesting, and only those become a dot. A {@code $} is a legal character in a
+ * Java identifier, so a class may have one in a name of its own making, and the enclosing chain
+ * is what tells the two apart.
+ */
+ private static List nestingBoundaries(Class> clazz) {
+ List boundaries = new ArrayList<>();
+ for (Class> enclosing = clazz.getEnclosingClass(); enclosing != null;
+ enclosing = enclosing.getEnclosingClass()) {
+ boundaries.add(enclosing.getName().length());
+ }
+ return boundaries;
}
/**
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..62405ef 100644
--- a/src/test/java/com/github/pjfanning/jackson/sealed/SealedTypesTest.java
+++ b/src/test/java/com/github/pjfanning/jackson/sealed/SealedTypesTest.java
@@ -53,9 +53,19 @@ void namesAnImplementationBesideANestedRootBySimpleName() {
@Test
void keepsTheEnclosingClassOfAnImplementationDeclaredElsewhere() {
assertThat(SealedTypes.typeNameFor(Fixtures.Dup.class, Fixtures.FirstGroup.Same.class))
- .isEqualTo("FirstGroup$Same");
+ .isEqualTo("FirstGroup.Same");
assertThat(SealedTypes.typeNameFor(Fixtures.Dup.class, Fixtures.SecondGroup.Same.class))
- .isEqualTo("SecondGroup$Same");
+ .isEqualTo("SecondGroup.Same");
+ }
+
+ /** Only a `$` that separates a class from the one enclosing it becomes a dot. */
+ @Test
+ void turnsOnlyNestingBoundariesIntoDots() {
+ assertThat(SealedTypes.typeNameFor(Fixtures.Expr.class, Fixtures.Grouped.Inner.class))
+ .isEqualTo("Grouped.Inner");
+ assertThat(SealedTypes.typeNameFor(Fixtures.Expr.class, Fixtures.Odd$Name.class))
+ .isEqualTo("Odd$Name");
+ assertThat(SealedTypes.typeNameFor(Fixtures.Expr.class, Fixtures.Lit.class)).isEqualTo("Lit");
}
@Test
diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/Fixtures.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/Fixtures.java
index 318ee65..5dfedaf 100644
--- a/src/test/java/com/github/pjfanning/jackson/sealed/poly/Fixtures.java
+++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/Fixtures.java
@@ -303,6 +303,29 @@ public String json() {
public record ModeHolder(Mode mode) {
}
+ // `$` is legal in a Java identifier, so a class can carry one that is not nesting and must
+ // survive intact - the counterpart of Scala compiling `::` to `$colon$colon`
+ public sealed interface Expr extends SealedPolymorphismSupport permits Lit, Odd$Name, Grouped.Inner {
+ }
+
+ public record Lit(int v) implements Expr {
+ }
+
+ @SuppressWarnings("checkstyle:TypeName")
+ public record Odd$Name(int head, int tail) implements Expr {
+ }
+
+ public static final class Grouped {
+ private Grouped() {
+ }
+
+ public record Inner(int v) implements Expr {
+ }
+ }
+
+ public record ExprHolder(Expr expr) {
+ }
+
// a hierarchy that is not marked - it must be untouched
public sealed interface Plain permits PlainDog {
}
diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInTest.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInTest.java
index ba269aa..84ab87e 100644
--- a/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInTest.java
+++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/MixInTest.java
@@ -110,9 +110,9 @@ void ignoresAMixInThatDoesNotCarryTheMarker() {
@Test
void appliesTheSameNamingRulesToAMixedInHierarchy() {
assertThat(mapper.writeValueAsString(new Manifest(new Hold.Item(1))))
- .isEqualTo("{\"cargo\":{\"@type\":\"Hold$Item\",\"n\":1}}");
+ .isEqualTo("{\"cargo\":{\"@type\":\"Hold.Item\",\"n\":1}}");
assertThat(mapper.writeValueAsString(new Manifest(new Deck.Item("x"))))
- .isEqualTo("{\"cargo\":{\"@type\":\"Deck$Item\",\"s\":\"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")));
}
diff --git a/src/test/java/com/github/pjfanning/jackson/sealed/poly/SealedPolymorphismTest.java b/src/test/java/com/github/pjfanning/jackson/sealed/poly/SealedPolymorphismTest.java
index 5ebb288..0913b15 100644
--- a/src/test/java/com/github/pjfanning/jackson/sealed/poly/SealedPolymorphismTest.java
+++ b/src/test/java/com/github/pjfanning/jackson/sealed/poly/SealedPolymorphismTest.java
@@ -169,11 +169,31 @@ void leavesOtherImplementationsOfThatHierarchyWorking() {
@Test
void qualifiesImplementationsDeclaredInClassesThatDoNotEncloseTheBase() {
assertThat(mapper.writeValueAsString(new DupHolder(new FirstGroup.Same(1))))
- .isEqualTo("{\"d\":{\"@type\":\"FirstGroup$Same\",\"v\":1}}");
+ .isEqualTo("{\"d\":{\"@type\":\"FirstGroup.Same\",\"v\":1}}");
assertThat(mapper.writeValueAsString(new DupHolder(new SecondGroup.Same("x"))))
- .isEqualTo("{\"d\":{\"@type\":\"SecondGroup$Same\",\"v\":\"x\"}}");
+ .isEqualTo("{\"d\":{\"@type\":\"SecondGroup.Same\",\"v\":\"x\"}}");
assertThat(mapper.writeValueAsString(new DupHolder(new FirstGroup.Only())))
- .isEqualTo("{\"d\":{\"@type\":\"FirstGroup$Only\"}}");
+ .isEqualTo("{\"d\":{\"@type\":\"FirstGroup.Only\"}}");
+ }
+
+ /** A dot separates a nested implementation from what encloses it, as jackson-databind does. */
+ @Test
+ void usesADotForTheClassEnclosingAnImplementation() {
+ String json = mapper.writeValueAsString(new Fixtures.ExprHolder(new Fixtures.Grouped.Inner(5)));
+ assertThat(json).isEqualTo("{\"expr\":{\"@type\":\"Grouped.Inner\",\"v\":5}}");
+ assertThat(mapper.readValue(json, Fixtures.ExprHolder.class))
+ .isEqualTo(new Fixtures.ExprHolder(new Fixtures.Grouped.Inner(5)));
+ }
+
+ /** A dollar that is part of a class's own name is not nesting, and must survive intact. */
+ @Test
+ void keepsADollarThatIsPartOfTheClassName() {
+ String json = mapper.writeValueAsString(new Fixtures.ExprHolder(new Fixtures.Odd$Name(1, 2)));
+ assertThat(json).isEqualTo("{\"expr\":{\"@type\":\"Odd$Name\",\"head\":1,\"tail\":2}}");
+ assertThat(mapper.readValue(json, Fixtures.ExprHolder.class))
+ .isEqualTo(new Fixtures.ExprHolder(new Fixtures.Odd$Name(1, 2)));
+ assertThat(mapper.writeValueAsString(new Fixtures.ExprHolder(new Fixtures.Lit(3))))
+ .isEqualTo("{\"expr\":{\"@type\":\"Lit\",\"v\":3}}");
}
@Test