From be2f485c077b9a1b7c055793feb63ab333c2d21e Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Sun, 23 Aug 2026 21:38:52 +0100 Subject: [PATCH] Separate a nested implementation with a dot, not a dollar Following jackson-module-scala#839. An implementation nested in a class other than the root was written {"@type":"Boxed$Same"}; it is now {"@type":"Boxed.Same"}, which is how jackson-databind separates a name from what encloses it. The JVM writes that boundary as a $, so the two spellings differ and this is a change to the JSON. Only a real nesting boundary becomes a dot. A $ is a legal character in a Java identifier, so a class can carry one in a name of its own making, and the enclosing class chain is what tells the two apart - a class with a $ in its own name has no enclosing class at that position. This is the counterpart of the Scala module's concern with `::` compiling to $colon$colon, and there is now a fixture named Odd$Name that asserts such a name survives intact. The Scala module has to convert the dot back to a $ before looking a name up, since it resolves by rebuilding class names and calling Class.forName. Nothing of the sort is needed here: names are resolved against the hierarchy's own table, built from the PermittedSubclasses attribute, so the derived name is simply the key. A dot in a @type still cannot address a package. 93 tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012hxYY9KPDAMXADWzjBHNV4 --- README.md | 17 +++++---- .../sealed/SealedPolymorphismSupport.java | 4 +-- .../pjfanning/jackson/sealed/SealedTypes.java | 36 ++++++++++++++++--- .../jackson/sealed/SealedTypesTest.java | 14 ++++++-- .../jackson/sealed/poly/Fixtures.java | 23 ++++++++++++ .../jackson/sealed/poly/MixInTest.java | 4 +-- .../sealed/poly/SealedPolymorphismTest.java | 26 ++++++++++++-- 7 files changed, 105 insertions(+), 19 deletions(-) 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