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.
dev
Exlll 3 years ago
parent a2153106b5
commit e5028e6199

@ -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;

@ -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<T> extends TypeSerializer<T, Field> {
final class ConfigurationSerializer<T> extends TypeSerializer<T, ConfigurationField> {
ConfigurationSerializer(Class<T> configurationType, ConfigurationProperties properties) {
super(Validator.requireConfiguration(configurationType), properties);
}
@Override
public Map<?, ?> serialize(T element) {
final Map<String, Object> 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<T> extends TypeSerializer<T, Field> {
}
@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<T> extends TypeSerializer<T, Field> {
}
@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<ConfigurationField> 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<T> extends TypeSerializer<T, Field> {
}
}
private List<Field> filterFields() {
return FieldExtractors.CONFIGURATION.extract(type)
.filter(properties.getFieldFilter())
.toList();
}
Class<T> getConfigurationType() {
return type;
}

@ -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<R extends Record> extends TypeSerializer<R, RecordComponent> {
final class RecordSerializer<R> extends
TypeSerializer<R, ConfigurationRecordComponent> {
RecordSerializer(Class<R> recordType, ConfigurationProperties properties) {
super(Validator.requireRecord(recordType), properties);
}
@Override
public Map<?, ?> serialize(R element) {
final Map<String, Object> 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<R extends Record> extends TypeSerializer<R, RecordC
}
@Override
protected void requireSerializableParts() {
protected void requireSerializableComponents() {
if (serializers.isEmpty()) {
String msg = "Record type '%s' does not define any components."
.formatted(type.getSimpleName());
@ -72,9 +53,21 @@ final class RecordSerializer<R extends Record> extends TypeSerializer<R, RecordC
}
@Override
protected String baseDeserializeExceptionMessage(RecordComponent component, Object value) {
protected String baseDeserializeExceptionMessage(ConfigurationRecordComponent component, Object value) {
return "Deserialization of value '%s' with type '%s' for component '%s' of record '%s' failed."
.formatted(value, value.getClass(), component, component.getDeclaringRecord());
.formatted(value, value.getClass(), component.component(), component.declaringType());
}
@Override
protected List<ConfigurationRecordComponent> components() {
return Arrays.stream(type.getRecordComponents())
.map(ConfigurationRecordComponent::new)
.toList();
}
@Override
R newDefaultInstance() {
return Reflect.newRecordDefaultValues(type);
}
private static void requireNonPrimitiveComponentType(RecordComponent component) {

@ -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<Class<?>, Object> DEFAULT_VALUES = initDefaultValues();
@ -60,9 +62,11 @@ final class Reflect {
}
}
static <R extends Record> R newRecord(Class<R> recordType, Object... constructorArguments) {
// We could use <R extends Record> as a bound here and for the other methods below
// but that would require casts elsewhere.
static <R> R newRecord(Class<R> recordType, Object... constructorArguments) {
try {
Constructor<R> constructor = getCanonicalConstructor(recordType);
Constructor<R> constructor = getCanonicalConstructor(requireRecord(recordType));
constructor.setAccessible(true);
return constructor.newInstance(constructorArguments);
} catch (NoSuchMethodException e) {
@ -81,12 +85,23 @@ final class Reflect {
}
}
static <R extends Record> Constructor<R> getCanonicalConstructor(Class<R> recordType)
static <R> R newRecordDefaultValues(Class<R> recordType) {
final Object[] args = Arrays.stream(recordParameterTypes(requireRecord(recordType)))
.map(Reflect::getDefaultValue)
.toArray(Object[]::new);
return Reflect.newRecord(recordType, args);
}
static <R> Constructor<R> getCanonicalConstructor(Class<R> recordType)
throws NoSuchMethodException {
Class<?>[] parameterTypes = Arrays.stream(recordType.getRecordComponents())
Class<?>[] parameterTypes = recordParameterTypes(requireRecord(recordType));
return recordType.getDeclaredConstructor(parameterTypes);
}
private static <R> Class<?>[] recordParameterTypes(Class<R> recordType) {
return Arrays.stream(recordType.getRecordComponents())
.map(RecordComponent::getType)
.toArray(Class<?>[]::new);
return recordType.getDeclaredConstructor(parameterTypes);
}
static <T> T[] newArray(Class<T> componentType, int length) {

@ -43,6 +43,12 @@ sealed interface TypeComponent<T> {
*/
Object componentValue(Object componentHolder);
/**
* Returns the type that declares this component.
*
* @return the declaring type
*/
Class<?> declaringType();
record ConfigurationField(Field component) implements TypeComponent<Field> {
public ConfigurationField(Field component) {
@ -63,6 +69,11 @@ sealed interface TypeComponent<T> {
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<T> {
public Object componentValue(Object componentHolder) {
return Reflect.getValue(component, componentHolder);
}
@Override
public Class<?> declaringType() {
return component.getDeclaringRecord();
}
}
}

@ -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<T, P> implements Serializer<T, Map<?, ?>> {
sealed abstract class TypeSerializer<T, TC extends TypeComponent<?>>
implements Serializer<T, Map<?, ?>>
permits ConfigurationSerializer, RecordSerializer {
protected final Class<T> type;
protected final ConfigurationProperties properties;
protected final NameFormatter formatter;
protected final Map<String, Serializer<?, ?>> serializers;
protected TypeSerializer(Class<T> 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 <T> TypeSerializer<T, ?> newSerializerFor(
Class<T> type,
ConfigurationProperties properties
) {
return type.isRecord()
? new RecordSerializer<>(type, properties)
: new ConfigurationSerializer<>(type, properties);
}
@Override
public final Map<?, ?> serialize(T element) {
final Map<String, Object> 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<Object, Object>) serializers.get(partName);
final var serializer = (Serializer<Object, Object>)
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<Object, Object>) serializers.get(partName);
final var serializer = (Serializer<Object, Object>)
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<TC> components();
protected abstract String baseDeserializeExceptionMessage(P part, Object value);
abstract T newDefaultInstance();
}

@ -31,7 +31,7 @@ public final class YamlConfigurationStore<T> implements FileConfigurationStore<T
private static final Load YAML_LOADER = newYamlLoader();
private final Class<T> configurationType;
private final YamlConfigurationProperties properties;
private final ConfigurationSerializer<T> serializer;
private final TypeSerializer<T, ?> serializer;
private final CommentNodeExtractor extractor;
/**
@ -44,7 +44,7 @@ public final class YamlConfigurationStore<T> implements FileConfigurationStore<T
public YamlConfigurationStore(Class<T> 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<T> implements FileConfigurationStore<T
public T load(Path configurationFile) {
try (var reader = Files.newBufferedReader(configurationFile)) {
var yaml = YAML_LOADER.loadFromReader(reader);
var conf = requireConfiguration(yaml, configurationFile);
var conf = requireYamlMap(yaml, configurationFile);
return serializer.deserialize(conf);
} catch (YamlEngineException e) {
String msg = "The configuration file at %s does not contain valid YAML.";
@ -93,7 +93,7 @@ public final class YamlConfigurationStore<T> implements FileConfigurationStore<T
}
}
private Map<?, ?> 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<T> implements FileConfigurationStore<T
save(configuration, configurationFile);
return configuration;
}
T configuration = Reflect.newInstance(configurationType);
T configuration = serializer.newDefaultInstance();
save(configuration, configurationFile);
return configuration;
}

@ -10,10 +10,10 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import static de.exlll.configlib.TestUtils.assertThrowsRuntimeException;
import static de.exlll.configlib.TestUtils.getField;
import static de.exlll.configlib.TestUtils.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.*;
class ReflectTest {
@ -305,4 +305,36 @@ class ReflectTest {
"The canonical constructor of record type 'R3' threw an exception."
);
}
@Test
void newRecordRequiresRecordType() {
class A {}
assertThrowsConfigurationException(
() -> 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);
}
}

@ -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));
}
}
}

@ -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

@ -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<R> 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<R> 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<R> 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<E> 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<R> 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<E> 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<R> 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 <T> YamlConfigurationStore<T> newDefaultStore(Class<T> configType) {
YamlConfigurationProperties properties = YamlConfigurationProperties.newBuilder().build();
return new YamlConfigurationStore<>(configType, properties);

Loading…
Cancel
Save