diff --git a/configlib-core/src/main/java/de/exlll/configlib/Polymorphic.java b/configlib-core/src/main/java/de/exlll/configlib/Polymorphic.java index 71c0bb3..3efbdc9 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/Polymorphic.java +++ b/configlib-core/src/main/java/de/exlll/configlib/Polymorphic.java @@ -13,7 +13,8 @@ import java.lang.annotation.Target; *

* For correct deserialization, if an instance of polymorphic type (or one of its implementations / * subclasses) is serialized, an additional property that holds type information is added to its - * serialization. + * serialization. The type information can be customized using the {@link PolymorphicTypes} + * annotation. * *

  * {@code
@@ -39,6 +40,8 @@ import java.lang.annotation.Target;
  * List bs = List.of(new Impl1(...), new Impl2(...), ...);
  * }
  * 
+ * + * @see PolymorphicTypes */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) diff --git a/configlib-core/src/main/java/de/exlll/configlib/PolymorphicSerializer.java b/configlib-core/src/main/java/de/exlll/configlib/PolymorphicSerializer.java index 6624f67..bd75308 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/PolymorphicSerializer.java +++ b/configlib-core/src/main/java/de/exlll/configlib/PolymorphicSerializer.java @@ -1,36 +1,77 @@ package de.exlll.configlib; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; final class PolymorphicSerializer implements Serializer> { private final SerializerContext context; private final Class polymorphicType; - private final Polymorphic annotation; + private final Polymorphic polymorphic; + private final Map> typeByAlias = new HashMap<>(); + private final Map, String> aliasByType = new HashMap<>(); public PolymorphicSerializer(SerializerContext context) { this.context = context; // we know it's a class because of SerializerSelector#findMetaSerializerOnType this.polymorphicType = (Class) context.annotatedType().getType(); - this.annotation = polymorphicType.getAnnotation(Polymorphic.class); + this.polymorphic = polymorphicType.getAnnotation(Polymorphic.class); requireNonBlankProperty(); + initAliases(); } private void requireNonBlankProperty() { - if (annotation.property().isBlank()) { + if (polymorphic.property().isBlank()) { String msg = "The @Polymorphic annotation does not allow a blank property name but " + "type '%s' uses one.".formatted(polymorphicType.getName()); throw new ConfigurationException(msg); } } + private void initAliases() { + final var polymorphicTypes = polymorphicType.getAnnotation(PolymorphicTypes.class); + + if (polymorphicTypes == null) + return; + + for (PolymorphicTypes.Type pType : polymorphicTypes.value()) { + final var type = pType.type(); + final var alias = pType.alias().isBlank() ? type.getName() : pType.alias(); + + requireDistinctAliases(alias); + requireDistinctTypes(type); + + typeByAlias.put(alias, type); + aliasByType.put(type, alias); + } + } + + private void requireDistinctAliases(String alias) { + if (typeByAlias.containsKey(alias)) { + String msg = "The @PolymorphicTypes annotation must not use the same alias for " + + "multiple types. Alias '%s' appears more than once." + .formatted(alias); + throw new ConfigurationException(msg); + } + } + + private void requireDistinctTypes(Class type) { + if (aliasByType.containsKey(type)) { + String msg = "The @PolymorphicTypes annotation must not contain multiple " + + "definitions for the same subtype. Type '%s' appears more than once." + .formatted(type.getName()); + throw new ConfigurationException(msg); + } + } + @Override public Map serialize(Object element) { + final Class elementType = element.getClass(); // this cast won't cause any exceptions as we only pass objects of types the // serializer expects @SuppressWarnings("unchecked") final var serializer = (TypeSerializer) TypeSerializer.newSerializerFor( - element.getClass(), + elementType, context.properties() ); final var serialization = serializer.serialize(element); @@ -38,34 +79,43 @@ final class PolymorphicSerializer implements Serializer> { requireSerializationNotContainsProperty(serialization); final var result = new LinkedHashMap<>(); - result.put(annotation.property(), element.getClass().getName()); + result.put(polymorphic.property(), getTypeIdentifierByType(elementType)); result.putAll(serialization); return result; } + private String getTypeIdentifierByType(Class type) { + final String alias = aliasByType.get(type); + return (alias == null) ? type.getName() : alias; + } + private void requireSerializationNotContainsProperty(Map serialization) { - if (serialization.containsKey(annotation.property())) { + if (serialization.containsKey(polymorphic.property())) { String msg = ("Polymorphic serialization for type '%s' failed. The type contains a " + "configuration element with name '%s' but that name is " + "used by the @Polymorphic property.") - .formatted(polymorphicType.getName(), annotation.property()); + .formatted(polymorphicType.getName(), polymorphic.property()); throw new ConfigurationException(msg); } } - @Override public Object deserialize(Map element) { requirePropertyPresent(element); - final var typeIdentifier = element.get(annotation.property()); + final var typeIdentifier = element.get(polymorphic.property()); requireTypeIdentifierString(typeIdentifier); - Class type = tryFindClass((String) typeIdentifier); - TypeSerializer serializer = TypeSerializer.newSerializerFor(type, context.properties()); + final var type = getTypeByTypeIdentifier((String) typeIdentifier); + final var serializer = TypeSerializer.newSerializerFor(type, context.properties()); return serializer.deserialize(element); } + private Class getTypeByTypeIdentifier(String typeIdentifier) { + final Class type = typeByAlias.get(typeIdentifier); + return (type == null) ? tryFindClass(typeIdentifier) : type; + } + private Class tryFindClass(String className) { try { return Reflect.getClassByName(className); @@ -78,7 +128,7 @@ final class PolymorphicSerializer implements Serializer> { } private void requirePropertyPresent(Map element) { - if (element.get(annotation.property()) != null) + if (element.get(polymorphic.property()) != null) return; String msg = """ Polymorphic deserialization for type '%s' failed. \ @@ -88,7 +138,7 @@ final class PolymorphicSerializer implements Serializer> { """ .formatted( polymorphicType.getName(), - annotation.property(), + polymorphic.property(), element ); throw new ConfigurationException(msg); diff --git a/configlib-core/src/main/java/de/exlll/configlib/PolymorphicTypes.java b/configlib-core/src/main/java/de/exlll/configlib/PolymorphicTypes.java new file mode 100644 index 0000000..1a59186 --- /dev/null +++ b/configlib-core/src/main/java/de/exlll/configlib/PolymorphicTypes.java @@ -0,0 +1,64 @@ +package de.exlll.configlib; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates the subtypes of {@code Polymorphic} types. This annotation can be used to provide type + * aliases for subtypes which are then used instead of Java class names. + * + *
+ * {@code
+ * @Polymorphic
+ * @PolymorphicTypes({
+ *         @PolymorphicTypes.Type(type = Impl1.class, alias = "IMPL_1"),
+ *         @PolymorphicTypes.Type(type = Impl2.class, alias = "IMPL_2")
+ * }
+ * })
+ * {@code
+ * interface A { ... }
+ *
+ * record Impl1(...) implements A { ... }
+ * record Impl2(...) implements A { ... }
+ *
+ * List as = List.of(new Impl1(...), new Impl2(...), ...);
+ * }
+ * 
+ * + * @see Polymorphic + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface PolymorphicTypes { + /** + * Returns (possibly only a subset of) the subtypes of the annotated type. + * + * @return subtypes of the annotated type + */ + Type[] value(); + + /** + * Indicates a subtype of a {@code Polymorphic} type. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @interface Type { + /** + * Returns the class of the subtype. + * + * @return class of the subtype + */ + Class type(); + + /** + * Returns the alias of the subtype. If the alias returned by this method is blank, + * the Java class name ist used. + * + * @return alias of the subtype + * @see String#isBlank() + */ + String alias() default ""; + } +} diff --git a/configlib-core/src/main/java/de/exlll/configlib/SerializeWith.java b/configlib-core/src/main/java/de/exlll/configlib/SerializeWith.java index a689ec2..5da2294 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/SerializeWith.java +++ b/configlib-core/src/main/java/de/exlll/configlib/SerializeWith.java @@ -1,6 +1,9 @@ package de.exlll.configlib; -import java.lang.annotation.*; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; /** * Indicates that the annotated configuration element or type should be serialized using the diff --git a/configlib-core/src/test/java/de/exlll/configlib/PolymorphicSerializerTest.java b/configlib-core/src/test/java/de/exlll/configlib/PolymorphicSerializerTest.java index 8815cb3..0221f15 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/PolymorphicSerializerTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/PolymorphicSerializerTest.java @@ -80,6 +80,111 @@ class PolymorphicSerializerTest { ); } + static final class PolymorphicTypesTest { + @Polymorphic + @PolymorphicTypes({ + @PolymorphicTypes.Type(type = Impl1.class, alias = "IMPL_1"), + @PolymorphicTypes.Type(type = Impl2.class, alias = "IMPL_2"), + @PolymorphicTypes.Type(type = Impl3.class, alias = " "), // blank alias + /* @PolymorphicTypes.Type(type = Impl4.class) */ // missing + }) + interface A {} + + record Impl1(int i) implements A {} + + record Impl2(double d) implements A {} + + record Impl3(String s) implements A {} + + record Impl4(long l) implements A {} + + record Config(List as) {} + + @Test + void serializeUsesTypeAliasIfPresent() { + @SuppressWarnings("unchecked") + var serializer = (ListSerializer) + SELECTOR.select(fieldAsElement(Config.class, "as")); + List serialized = serializer.serialize(List.of( + new Impl1(1), + new Impl2(2d), + new Impl3("3"), + new Impl4(4) + )); + assertThat(serialized, is(List.of( + asMap("type", "IMPL_1", "i", 1L), + asMap("type", "IMPL_2", "d", 2d), + asMap("type", Impl3.class.getName(), "s", "3"), + asMap("type", Impl4.class.getName(), "l", 4L) + ))); + } + + @Test + void deserializeUsesTypeAliasIfPresent() { + @SuppressWarnings("unchecked") + var serializer = (ListSerializer>) + SELECTOR.select(fieldAsElement(Config.class, "as")); + List serialized = serializer.deserialize(List.of( + asMap("type", "IMPL_1", "i", 1L), + asMap("type", "IMPL_2", "d", 2d), + asMap("type", Impl3.class.getName(), "s", "3"), + asMap("type", Impl4.class.getName(), "l", 4L) + )); + assertThat(serialized.size(), is(4)); + assertThat(serialized.get(0), is(new Impl1(1))); + assertThat(serialized.get(1), is(new Impl2(2))); + assertThat(serialized.get(2), is(new Impl3("3"))); + assertThat(serialized.get(3), is(new Impl4(4))); + } + + @Test + void typesMustNotAppearMoreThanOnce() { + @Polymorphic + @PolymorphicTypes({ + @PolymorphicTypes.Type(type = Impl1.class, alias = "1a"), + @PolymorphicTypes.Type(type = Impl1.class, alias = "1b") + }) + interface E {} + record Config(E e) {} + + RuntimeException exception = Assertions.assertThrows( + RuntimeException.class, + () -> SELECTOR.select(fieldAsElement(Config.class, "e")) + ); + ConfigurationException configurationException = (ConfigurationException) + exception.getCause().getCause(); + Assertions.assertEquals( + "The @PolymorphicTypes annotation must not contain multiple definitions for " + + "the same subtype. Type '%s' appears more than once." + .formatted(Impl1.class.getName()), + configurationException.getMessage() + ); + } + + @Test + void aliasesMustNotAppearMoreThanOnce() { + @Polymorphic + @PolymorphicTypes({ + @PolymorphicTypes.Type(type = Impl1.class, alias = "2"), + @PolymorphicTypes.Type(type = Impl2.class, alias = "2") + }) + interface E {} + record Config(E e) {} + + RuntimeException exception = Assertions.assertThrows( + RuntimeException.class, + () -> SELECTOR.select(fieldAsElement(Config.class, "e")) + ); + ConfigurationException configurationException = (ConfigurationException) + exception.getCause().getCause(); + Assertions.assertEquals( + "The @PolymorphicTypes annotation must not use the same alias for multiple " + + "types. Alias '2' appears more than once.", + configurationException.getMessage() + ); + } + } + static final class PolymorphicSerializerPropertyTest { private static final String CUSTOM_PROPERTY = DEFAULT_PROPERTY + DEFAULT_PROPERTY; @@ -91,8 +196,6 @@ class PolymorphicSerializerTest { record R(int i) implements A {} - record S(int i) implements B {} - record Config(A a, B b, List as, List bs) {} @Test