From e5028e6199e315701bc9e290d9fb2c68eb938bc6 Mon Sep 17 00:00:00 2001 From: Exlll Date: Sat, 16 Jul 2022 03:15:04 +0200 Subject: [PATCH] Add top-level saving/loading support for Records Records can now be saved and loaded directly using one of the several methods available. Also, the TypeSerializer has been refactored. --- .../configlib/ConfigurationProperties.java | 5 +- .../configlib/ConfigurationSerializer.java | 65 +++++------ .../de/exlll/configlib/RecordSerializer.java | 77 ++++++------- .../main/java/de/exlll/configlib/Reflect.java | 25 ++++- .../de/exlll/configlib/TypeComponent.java | 16 +++ .../de/exlll/configlib/TypeSerializer.java | 60 ++++++++-- .../configlib/YamlConfigurationStore.java | 10 +- .../java/de/exlll/configlib/ReflectTest.java | 36 +++++- .../de/exlll/configlib/TypeComponentTest.java | 10 ++ .../YamlConfigurationPropertiesTest.java | 1 - .../configlib/YamlConfigurationStoreTest.java | 103 ++++++++++++++++++ 11 files changed, 303 insertions(+), 105 deletions(-) diff --git a/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java b/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java index f4fb134..80bd1f0 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java +++ b/configlib-core/src/main/java/de/exlll/configlib/ConfigurationProperties.java @@ -1,7 +1,10 @@ package de.exlll.configlib; import java.lang.reflect.Type; -import java.util.*; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.function.Predicate; import static de.exlll.configlib.Validator.requireNonNull; diff --git a/configlib-core/src/main/java/de/exlll/configlib/ConfigurationSerializer.java b/configlib-core/src/main/java/de/exlll/configlib/ConfigurationSerializer.java index 7af7315..6d27458 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/ConfigurationSerializer.java +++ b/configlib-core/src/main/java/de/exlll/configlib/ConfigurationSerializer.java @@ -1,52 +1,34 @@ package de.exlll.configlib; +import de.exlll.configlib.TypeComponent.ConfigurationField; + import java.lang.reflect.Field; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; -final class ConfigurationSerializer extends TypeSerializer { +final class ConfigurationSerializer extends TypeSerializer { ConfigurationSerializer(Class configurationType, ConfigurationProperties properties) { super(Validator.requireConfiguration(configurationType), properties); } - @Override - public Map serialize(T element) { - final Map result = new LinkedHashMap<>(); - - for (final Field field : filterFields()) { - final Object fieldValue = Reflect.getValue(field, element); - - if ((fieldValue == null) && !properties.outputNulls()) - continue; - - final Object serializedValue = serialize(field.getName(), fieldValue); - - final String formattedField = properties.getNameFormatter().format(field.getName()); - result.put(formattedField, serializedValue); - } - - return result; - } - @Override public T deserialize(Map element) { final T result = Reflect.newInstance(type); - for (final Field field : filterFields()) { - final String fieldFormatted = properties.getNameFormatter().format(field.getName()); + for (final var component : components()) { + final var formattedName = formatter.format(component.componentName()); - if (!element.containsKey(fieldFormatted)) + if (!element.containsKey(formattedName)) continue; - final Object serializedValue = element.get(fieldFormatted); + final var serializedValue = element.get(formattedName); + final var field = component.component(); - if (serializedValue == null && properties.inputNulls()) { + if ((serializedValue == null) && properties.inputNulls()) { requireNonPrimitiveFieldType(field); Reflect.setValue(field, result, null); } else if (serializedValue != null) { - final Object deserialized = deserialize(field, field.getName(), serializedValue); - Reflect.setValue(field, result, deserialized); + Object deserializeValue = deserialize(component, serializedValue); + Reflect.setValue(field, result, deserializeValue); } } @@ -54,7 +36,7 @@ final class ConfigurationSerializer extends TypeSerializer { } @Override - protected void requireSerializableParts() { + protected void requireSerializableComponents() { if (serializers.isEmpty()) { String msg = "Configuration class '" + type.getSimpleName() + "' " + "does not contain any (de-)serializable fields."; @@ -63,9 +45,22 @@ final class ConfigurationSerializer extends TypeSerializer { } @Override - protected String baseDeserializeExceptionMessage(Field field, Object value) { + protected String baseDeserializeExceptionMessage(ConfigurationField component, Object value) { return "Deserialization of value '%s' with type '%s' for field '%s' failed." - .formatted(value, value.getClass(), field); + .formatted(value, value.getClass(), component.component()); + } + + @Override + protected Iterable components() { + return FieldExtractors.CONFIGURATION.extract(type) + .filter(properties.getFieldFilter()) + .map(ConfigurationField::new) + .toList(); + } + + @Override + T newDefaultInstance() { + return Reflect.newInstance(type); } private static void requireNonPrimitiveFieldType(Field field) { @@ -76,12 +71,6 @@ final class ConfigurationSerializer extends TypeSerializer { } } - private List filterFields() { - return FieldExtractors.CONFIGURATION.extract(type) - .filter(properties.getFieldFilter()) - .toList(); - } - Class getConfigurationType() { return type; } diff --git a/configlib-core/src/main/java/de/exlll/configlib/RecordSerializer.java b/configlib-core/src/main/java/de/exlll/configlib/RecordSerializer.java index 6d2cb39..9124b55 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/RecordSerializer.java +++ b/configlib-core/src/main/java/de/exlll/configlib/RecordSerializer.java @@ -1,61 +1,42 @@ package de.exlll.configlib; +import de.exlll.configlib.TypeComponent.ConfigurationRecordComponent; + import java.lang.reflect.RecordComponent; -import java.util.LinkedHashMap; +import java.util.Arrays; +import java.util.List; import java.util.Map; -final class RecordSerializer extends TypeSerializer { +final class RecordSerializer extends + TypeSerializer { RecordSerializer(Class recordType, ConfigurationProperties properties) { super(Validator.requireRecord(recordType), properties); } - @Override - public Map serialize(R element) { - final Map result = new LinkedHashMap<>(); - - for (final RecordComponent component : type.getRecordComponents()) { - final Object componentValue = Reflect.getValue(component, element); - - if (componentValue == null && !properties.outputNulls()) - continue; - - final Object resultValue = serialize(component.getName(), componentValue); - - final String compName = properties.getNameFormatter().format(component.getName()); - result.put(compName, resultValue); - } - - return result; - } - @Override public R deserialize(Map element) { - final var components = type.getRecordComponents(); - final var constructorArguments = new Object[components.length]; + final var components = components(); + final var constructorArguments = new Object[components.size()]; - for (int i = 0; i < components.length; i++) { - final var component = components[i]; - final var componentFormatted = properties.getNameFormatter() - .format(component.getName()); + for (int i = 0, size = components.size(); i < size; i++) { + final var component = components.get(i); + final var formattedName = formatter.format(component.componentName()); - if (!element.containsKey(componentFormatted)) { - constructorArguments[i] = Reflect.getDefaultValue(component.getType()); + if (!element.containsKey(formattedName)) { + constructorArguments[i] = Reflect.getDefaultValue(component.componentType()); continue; } - final Object serializedArgument = element.get(componentFormatted); + final var serializedValue = element.get(formattedName); + final var recordComponent = component.component(); - if (serializedArgument == null && properties.inputNulls()) { - requireNonPrimitiveComponentType(component); + if ((serializedValue == null) && properties.inputNulls()) { + requireNonPrimitiveComponentType(recordComponent); constructorArguments[i] = null; - } else if (serializedArgument == null) { - constructorArguments[i] = Reflect.getDefaultValue(component.getType()); + } else if (serializedValue == null) { + constructorArguments[i] = Reflect.getDefaultValue(component.componentType()); } else { - constructorArguments[i] = deserialize( - component, - component.getName(), - serializedArgument - ); + constructorArguments[i] = deserialize(component, serializedValue); } } @@ -63,7 +44,7 @@ final class RecordSerializer extends TypeSerializer extends TypeSerializer components() { + return Arrays.stream(type.getRecordComponents()) + .map(ConfigurationRecordComponent::new) + .toList(); + } + + @Override + R newDefaultInstance() { + return Reflect.newRecordDefaultValues(type); } private static void requireNonPrimitiveComponentType(RecordComponent component) { diff --git a/configlib-core/src/main/java/de/exlll/configlib/Reflect.java b/configlib-core/src/main/java/de/exlll/configlib/Reflect.java index 35ed2b6..5f22836 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/Reflect.java +++ b/configlib-core/src/main/java/de/exlll/configlib/Reflect.java @@ -8,6 +8,8 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import static de.exlll.configlib.Validator.requireRecord; + final class Reflect { private static final Map, Object> DEFAULT_VALUES = initDefaultValues(); @@ -60,9 +62,11 @@ final class Reflect { } } - static R newRecord(Class recordType, Object... constructorArguments) { + // We could use as a bound here and for the other methods below + // but that would require casts elsewhere. + static R newRecord(Class recordType, Object... constructorArguments) { try { - Constructor constructor = getCanonicalConstructor(recordType); + Constructor constructor = getCanonicalConstructor(requireRecord(recordType)); constructor.setAccessible(true); return constructor.newInstance(constructorArguments); } catch (NoSuchMethodException e) { @@ -81,12 +85,23 @@ final class Reflect { } } - static Constructor getCanonicalConstructor(Class recordType) + static R newRecordDefaultValues(Class recordType) { + final Object[] args = Arrays.stream(recordParameterTypes(requireRecord(recordType))) + .map(Reflect::getDefaultValue) + .toArray(Object[]::new); + return Reflect.newRecord(recordType, args); + } + + static Constructor getCanonicalConstructor(Class recordType) throws NoSuchMethodException { - Class[] parameterTypes = Arrays.stream(recordType.getRecordComponents()) + Class[] parameterTypes = recordParameterTypes(requireRecord(recordType)); + return recordType.getDeclaredConstructor(parameterTypes); + } + + private static Class[] recordParameterTypes(Class recordType) { + return Arrays.stream(recordType.getRecordComponents()) .map(RecordComponent::getType) .toArray(Class[]::new); - return recordType.getDeclaredConstructor(parameterTypes); } static T[] newArray(Class componentType, int length) { diff --git a/configlib-core/src/main/java/de/exlll/configlib/TypeComponent.java b/configlib-core/src/main/java/de/exlll/configlib/TypeComponent.java index 9635296..c5add12 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/TypeComponent.java +++ b/configlib-core/src/main/java/de/exlll/configlib/TypeComponent.java @@ -43,6 +43,12 @@ sealed interface TypeComponent { */ Object componentValue(Object componentHolder); + /** + * Returns the type that declares this component. + * + * @return the declaring type + */ + Class declaringType(); record ConfigurationField(Field component) implements TypeComponent { public ConfigurationField(Field component) { @@ -63,6 +69,11 @@ sealed interface TypeComponent { public Object componentValue(Object componentHolder) { return Reflect.getValue(component, componentHolder); } + + @Override + public Class declaringType() { + return component.getDeclaringClass(); + } } record ConfigurationRecordComponent(RecordComponent component) @@ -85,6 +96,11 @@ sealed interface TypeComponent { public Object componentValue(Object componentHolder) { return Reflect.getValue(component, componentHolder); } + + @Override + public Class declaringType() { + return component.getDeclaringRecord(); + } } } diff --git a/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java b/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java index b92ffd0..c047a17 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java +++ b/configlib-core/src/main/java/de/exlll/configlib/TypeSerializer.java @@ -1,51 +1,89 @@ package de.exlll.configlib; +import java.util.LinkedHashMap; import java.util.Map; import static de.exlll.configlib.Validator.requireNonNull; -abstract class TypeSerializer implements Serializer> { +sealed abstract class TypeSerializer> + implements Serializer> + permits ConfigurationSerializer, RecordSerializer { protected final Class type; protected final ConfigurationProperties properties; + protected final NameFormatter formatter; protected final Map> serializers; protected TypeSerializer(Class type, ConfigurationProperties properties) { this.type = requireNonNull(type, "type"); this.properties = requireNonNull(properties, "configuration properties"); + this.formatter = properties.getNameFormatter(); this.serializers = new SerializerMapper(type, properties).buildSerializerMap(); - requireSerializableParts(); + requireSerializableComponents(); } - protected final Object serialize(String partName, Object value) { + static TypeSerializer newSerializerFor( + Class type, + ConfigurationProperties properties + ) { + return type.isRecord() + ? new RecordSerializer<>(type, properties) + : new ConfigurationSerializer<>(type, properties); + } + + @Override + public final Map serialize(T element) { + final Map result = new LinkedHashMap<>(); + + for (final TC component : components()) { + final Object componentValue = component.componentValue(element); + + if ((componentValue == null) && !properties.outputNulls()) + continue; + + final Object serializedValue = serialize(component, componentValue); + final String formattedName = formatter.format(component.componentName()); + result.put(formattedName, serializedValue); + } + + return result; + } + + protected final Object serialize(TC component, Object value) { // The following cast won't cause a ClassCastException because the serializers - // are selected based on the part type. + // are selected based on the component type. @SuppressWarnings("unchecked") - final var serializer = (Serializer) serializers.get(partName); + final var serializer = (Serializer) + serializers.get(component.componentName()); return (value != null) ? serializer.serialize(value) : null; } - protected final Object deserialize(P part, String partName, Object value) { + protected final Object deserialize(TC component, Object value) { // This unchecked cast leads to an exception if the type of the object which // is deserialized is not a subtype of the type the deserializer expects. @SuppressWarnings("unchecked") - final var serializer = (Serializer) serializers.get(partName); + final var serializer = (Serializer) + serializers.get(component.componentName()); final Object deserialized; try { deserialized = serializer.deserialize(value); } catch (ClassCastException e) { - String msg = baseDeserializeExceptionMessage(part, value) + "\n" + + String msg = baseDeserializeExceptionMessage(component, value) + "\n" + "The type of the object to be deserialized does not " + "match the type the deserializer expects."; throw new ConfigurationException(msg, e); } catch (RuntimeException e) { - String msg = baseDeserializeExceptionMessage(part, value); + String msg = baseDeserializeExceptionMessage(component, value); throw new ConfigurationException(msg, e); } return deserialized; } - protected abstract void requireSerializableParts(); + protected abstract void requireSerializableComponents(); + + protected abstract String baseDeserializeExceptionMessage(TC component, Object value); + + protected abstract Iterable components(); - protected abstract String baseDeserializeExceptionMessage(P part, Object value); + abstract T newDefaultInstance(); } diff --git a/configlib-core/src/main/java/de/exlll/configlib/YamlConfigurationStore.java b/configlib-core/src/main/java/de/exlll/configlib/YamlConfigurationStore.java index 91d13e0..0c8a6b5 100644 --- a/configlib-core/src/main/java/de/exlll/configlib/YamlConfigurationStore.java +++ b/configlib-core/src/main/java/de/exlll/configlib/YamlConfigurationStore.java @@ -31,7 +31,7 @@ public final class YamlConfigurationStore implements FileConfigurationStore configurationType; private final YamlConfigurationProperties properties; - private final ConfigurationSerializer serializer; + private final TypeSerializer serializer; private final CommentNodeExtractor extractor; /** @@ -44,7 +44,7 @@ public final class YamlConfigurationStore implements FileConfigurationStore configurationType, YamlConfigurationProperties properties) { this.configurationType = requireNonNull(configurationType, "configuration type"); this.properties = requireNonNull(properties, "properties"); - this.serializer = new ConfigurationSerializer<>(configurationType, properties); + this.serializer = TypeSerializer.newSerializerFor(configurationType, properties); this.extractor = new CommentNodeExtractor(properties); } @@ -83,7 +83,7 @@ public final class YamlConfigurationStore implements FileConfigurationStore implements FileConfigurationStore requireConfiguration(Object yaml, Path configurationFile) { + private Map requireYamlMap(Object yaml, Path configurationFile) { if (yaml == null) { String msg = "The configuration file at %s is empty or only contains null."; throw new ConfigurationException(msg.formatted(configurationFile)); @@ -116,7 +116,7 @@ public final class YamlConfigurationStore implements FileConfigurationStore Reflect.newRecord(A.class), + "Class 'A' must be a record." + ); + } + + @Test + void newRecordWithDefaultValues() { + record E() {} + record R(boolean a, char b, byte c, short d, int e, long f, float g, double h, + Boolean i, Character j, Integer k, Float l, E m, R n, Object o) {} + R r = Reflect.newRecordDefaultValues(R.class); + assertFalse(r.a); + assertEquals('\0', r.b); + assertEquals(0, r.c); + assertEquals(0, r.d); + assertEquals(0, r.e); + assertEquals(0, r.f); + assertEquals(0, r.g); + assertEquals(0, r.h); + assertNull(r.i); + assertNull(r.j); + assertNull(r.k); + assertNull(r.l); + assertNull(r.m); + assertNull(r.n); + assertNull(r.o); + } } \ No newline at end of file diff --git a/configlib-core/src/test/java/de/exlll/configlib/TypeComponentTest.java b/configlib-core/src/test/java/de/exlll/configlib/TypeComponentTest.java index f309dd2..9331f71 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/TypeComponentTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/TypeComponentTest.java @@ -35,6 +35,11 @@ class TypeComponentTest { void componentValue() { assertThat(COMPONENT.componentValue(new C()), is(20)); } + + @Test + void declaringType() { + assertThat(COMPONENT.declaringType(), equalTo(C.class)); + } } static final class ConfigurationRecordComponentTest { @@ -58,5 +63,10 @@ class TypeComponentTest { void componentValue() { assertThat(COMPONENT.componentValue(new R(10f)), is(10f)); } + + @Test + void declaringType() { + assertThat(COMPONENT.declaringType(), equalTo(R.class)); + } } } \ No newline at end of file diff --git a/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationPropertiesTest.java b/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationPropertiesTest.java index c655b5f..278f384 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationPropertiesTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationPropertiesTest.java @@ -4,7 +4,6 @@ import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.*; class YamlConfigurationPropertiesTest { @Test diff --git a/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationStoreTest.java b/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationStoreTest.java index 28425b7..daa70ec 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationStoreTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/YamlConfigurationStoreTest.java @@ -61,6 +61,33 @@ class YamlConfigurationStoreTest { assertEquals(expected, TestUtils.readFile(yamlFile)); } + @Test + void saveRecord() { + record R(String s, @Comment("A comment") Integer i) {} + YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder() + .header("The\nHeader") + .footer("The\nFooter") + .outputNulls(true) + .setNameFormatter(String::toUpperCase) + .build(); + YamlConfigurationStore store = new YamlConfigurationStore<>(R.class, properties); + store.save(new R("S1", null), yamlFile); + + String expected = + """ + # The + # Header + + S: S1 + # A comment + I: null + + # The + # Footer\ + """; + assertEquals(expected, TestUtils.readFile(yamlFile)); + } + @Configuration static final class B { String s = "S1"; @@ -97,6 +124,36 @@ class YamlConfigurationStoreTest { assertNull(config.i); } + @Test + void loadRecord() throws IOException { + record R(String s, String t, Integer i) {} + YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder() + .inputNulls(true) + .setNameFormatter(String::toUpperCase) + .build(); + YamlConfigurationStore store = new YamlConfigurationStore<>(R.class, properties); + + Files.writeString( + yamlFile, + """ + # The + # Header + + S: S2 + t: T2 + I: null + + # The + # Footer\ + """ + ); + + R config = store.load(yamlFile); + assertEquals("S2", config.s); + assertNull(config.t); + assertNull(config.i); + } + @Configuration static final class C { int i; @@ -207,6 +264,29 @@ class YamlConfigurationStoreTest { assertEquals(11, config.j); } + @Test + void updateCreatesConfigurationFileIfItDoesNotExistRecord() { + record R(int i, char c, String s) {} + YamlConfigurationStore store = new YamlConfigurationStore<>( + R.class, + YamlConfigurationProperties.newBuilder().outputNulls(true).build() + ); + + assertFalse(Files.exists(yamlFile)); + R config = store.update(yamlFile); + assertEquals( + """ + i: 0 + c: "\\0" + s: null\ + """, + readFile(yamlFile) + ); + assertEquals(0, config.i); + assertEquals('\0', config.c); + assertNull(config.s); + } + @Test void updateLoadsConfigurationFileIfItDoesExist() throws IOException { YamlConfigurationStore store = newDefaultStore(E.class); @@ -217,6 +297,17 @@ class YamlConfigurationStoreTest { assertEquals(11, config.j); } + @Test + void updateLoadsConfigurationFileIfItDoesExistRecord() throws IOException { + record R(int i, int j) {} + YamlConfigurationStore store = newDefaultStore(R.class); + + Files.writeString(yamlFile, "i: 20"); + R config = store.update(yamlFile); + assertEquals(20, config.i); + assertEquals(0, config.j); + } + @Test void updateUpdatesFile() throws IOException { YamlConfigurationStore store = newDefaultStore(E.class); @@ -228,6 +319,18 @@ class YamlConfigurationStoreTest { assertEquals("i: 20\nj: 11", readFile(yamlFile)); } + @Test + void updateUpdatesFileRecord() throws IOException { + record R(int i, int j) {} + YamlConfigurationStore store = newDefaultStore(R.class); + + Files.writeString(yamlFile, "i: 20\nk: 30"); + R config = store.update(yamlFile); + assertEquals(20, config.i); + assertEquals(0, config.j); + assertEquals("i: 20\nj: 0", readFile(yamlFile)); + } + private static YamlConfigurationStore newDefaultStore(Class configType) { YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder().build(); return new YamlConfigurationStore<>(configType, properties);