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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
| --- | --- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
* <pre>{@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"}
* }</pre>
*
* <p>Unlike Scala, {@code javac} records a sealed hierarchy in the class file, so the set of
Expand Down
36 changes: 32 additions & 4 deletions src/main/java/com/github/pjfanning/jackson/sealed/SealedTypes.java
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,46 @@ static void checkNoConflictingJsonTypeInfo(MixInResolver mixIns, Class<?> clazz)
*
* <p>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.
*
* <p>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<Integer> nestingBoundaries(Class<?> clazz) {
List<Integer> boundaries = new ArrayList<>();
for (Class<?> enclosing = clazz.getEnclosingClass(); enclosing != null;
enclosing = enclosing.getEnclosingClass()) {
boundaries.add(enclosing.getName().length());
}
return boundaries;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down