From 3b42dec35fc347156c68e2c4e9dbcd1ad25ba8a7 Mon Sep 17 00:00:00 2001 From: el211 Date: Wed, 3 Jun 2026 23:09:54 +0200 Subject: [PATCH 01/12] feat: add structura-writers module Adds the structura-writers module with YAML serialization support: - StructuraWriters: static API to write a Loadable to a file or generate defaults - LoadableSerializer: converts a record to YAML (kebab-case keys, nested records, collections, custom writers) - DefaultInstanceFactory: instantiates a record from @Default* annotations and zero-values - CustomWriterRegistry: singleton registry for custom Writer serializers - Basic test coverage for the write/saveDefault/serializer/factory paths --- .claude/settings.local.json | 10 ++ settings.gradle.kts | 4 +- structura-writers/build.gradle.kts | 45 +++++++ .../structura/writers/StructuraWriters.java | 57 +++++++++ .../exceptions/StructuraWriterException.java | 15 +++ .../factory/DefaultInstanceFactory.java | 82 +++++++++++++ .../registries/CustomWriterRegistry.java | 63 ++++++++++ .../serializer/LoadableSerializer.java | 110 ++++++++++++++++++ .../structura/writers/writer/Writer.java | 15 +++ .../writers/StructuraWritersTest.java | 61 ++++++++++ .../factory/DefaultInstanceFactoryTest.java | 42 +++++++ .../writers/fixtures/WriterTestModels.java | 82 +++++++++++++ .../serializer/LoadableSerializerTest.java | 51 ++++++++ 13 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json create mode 100644 structura-writers/build.gradle.kts create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java create mode 100644 structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..60b3047 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew :structura-writers:test)", + "Bash(./gradlew test:*)", + "Bash(./gradlew :structura-writers:compileJava)", + "Bash(./gradlew :structura-writers:test --info)" + ] + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 08842a3..fec4e94 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,4 +8,6 @@ pluginManagement { } gradlePluginPortal() } -} \ No newline at end of file +} + +include("structura-writers") \ No newline at end of file diff --git a/structura-writers/build.gradle.kts b/structura-writers/build.gradle.kts new file mode 100644 index 0000000..5a67b77 --- /dev/null +++ b/structura-writers/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("java-library") +} + +group = "fr.traqueur" +version = rootProject.property("version") as String + +repositories { + mavenCentral() +} + +dependencies { + api(project(":")) + compileOnly("org.yaml:snakeyaml:2.4") + + testImplementation(project(":")) + testImplementation("org.yaml:snakeyaml:2.4") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") +} + +val targetJavaVersion = 21 +java { + val javaVersion = JavaVersion.toVersion(targetJavaVersion) + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + if (JavaVersion.current() < javaVersion) { + toolchain.languageVersion.set(JavaLanguageVersion.of(targetJavaVersion)) + } +} + +tasks.test { + useJUnitPlatform() + jvmArgs = listOf("-XX:+EnableDynamicAgentLoading") + + reports { + html.required.set(true) + junitXml.required.set(true) + } + + testLogging { + showStandardStreams = true + events("passed", "skipped", "failed", "standardOut", "standardError") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java new file mode 100644 index 0000000..484375d --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java @@ -0,0 +1,57 @@ +package fr.traqueur.structura.writers; + +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.factory.DefaultInstanceFactory; +import fr.traqueur.structura.writers.serializer.LoadableSerializer; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Objects; + +/** + * Main entry point for the structura-writers module. + * Provides static methods to write a {@link Loadable} instance to a YAML file, + * or to generate a default file from {@code @Default*} annotations. + */ +public final class StructuraWriters { + + private static final LoadableSerializer SERIALIZER = new LoadableSerializer(); + private static final DefaultInstanceFactory DEFAULT_FACTORY = new DefaultInstanceFactory(); + + private StructuraWriters() {} + + /** + * Serializes {@code config} to YAML and writes it to {@code file}. + * Creates the file if it does not exist, overwrites it otherwise. + * + * @throws StructuraWriterException if the write fails + */ + public static void write(Path file, Loadable config) { + Objects.requireNonNull(file, "file cannot be null"); + Objects.requireNonNull(config, "config cannot be null"); + + String yaml = SERIALIZER.toYaml(config); + try { + Files.writeString(file, yaml, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + throw new StructuraWriterException("Failed to write configuration to: " + file.toAbsolutePath(), e); + } + } + + /** + * Creates a default instance of {@code configClass} using {@code @Default*} annotations, + * then writes it to {@code file}. Does not check whether the file already exists. + * + * @throws StructuraWriterException if instance creation or the write fails + */ + public static void saveDefault(Path file, Class configClass) { + Objects.requireNonNull(file, "file cannot be null"); + Objects.requireNonNull(configClass, "configClass cannot be null"); + + T defaultInstance = DEFAULT_FACTORY.createDefault(configClass); + write(file, defaultInstance); + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java new file mode 100644 index 0000000..800f078 --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/exceptions/StructuraWriterException.java @@ -0,0 +1,15 @@ +package fr.traqueur.structura.writers.exceptions; + +import fr.traqueur.structura.exceptions.StructuraException; + +/** Thrown by the structura-writers module when serialization or instance creation fails. */ +public class StructuraWriterException extends StructuraException { + + public StructuraWriterException(String message) { + super(message); + } + + public StructuraWriterException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java new file mode 100644 index 0000000..89a9ae0 --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactory.java @@ -0,0 +1,82 @@ +package fr.traqueur.structura.writers.factory; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.registries.DefaultValueRegistry; +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.lang.reflect.RecordComponent; +import java.util.*; + +/** + * Creates a default instance of a {@link Loadable} record by resolving + * values from {@code @Default*} annotations and Java zero-values. + */ +public class DefaultInstanceFactory { + + /** + * Instantiates {@code configClass} with default values. + * Resolution order: {@code @Default*} annotations → nested records → empty collections → zero-values. + * + * @throws StructuraWriterException if the class is not a record or instantiation fails + */ + public T createDefault(Class configClass) { + if (configClass == null || !configClass.isRecord()) { + throw new StructuraWriterException( + (configClass == null ? "null" : configClass.getName()) + " is not a record type."); + } + + RecordComponent[] components = configClass.getRecordComponents(); + Constructor constructor = configClass.getDeclaredConstructors()[0]; + Parameter[] parameters = constructor.getParameters(); + Object[] args = new Object[components.length]; + + for (int i = 0; i < components.length; i++) { + args[i] = resolveDefault(components[i], parameters[i]); + } + + try { + constructor.setAccessible(true); + return configClass.cast(constructor.newInstance(args)); + } catch (ReflectiveOperationException e) { + throw new StructuraWriterException("Failed to create default instance of " + configClass.getName(), e); + } + } + + private Object resolveDefault(RecordComponent component, Parameter parameter) { + Class type = component.getType(); + List annotations = Arrays.asList(parameter.getAnnotations()); + + Object registryDefault = DefaultValueRegistry.getInstance().getDefaultValue(type, annotations); + if (registryDefault != null) return registryDefault; + + if (type.isRecord() && Loadable.class.isAssignableFrom(type)) { + @SuppressWarnings("unchecked") + Class nested = (Class) type; + return createDefault(nested); + } + + if (List.class.isAssignableFrom(type)) return new ArrayList<>(); + if (Set.class.isAssignableFrom(type)) return new HashSet<>(); + if (Map.class.isAssignableFrom(type)) return new LinkedHashMap<>(); + + if (type == int.class || type == Integer.class) return 0; + if (type == long.class || type == Long.class) return 0L; + if (type == double.class || type == Double.class) return 0.0; + if (type == float.class || type == Float.class) return 0.0f; + if (type == boolean.class || type == Boolean.class) return false; + if (type == byte.class || type == Byte.class) return (byte) 0; + if (type == short.class || type == Short.class) return (short) 0; + if (type == char.class || type == Character.class) return '\0'; + + Options options = parameter.getAnnotation(Options.class); + if (options != null && options.optional()) return null; + + if (type == String.class) return ""; + + return null; + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java new file mode 100644 index 0000000..779ac7b --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/registries/CustomWriterRegistry.java @@ -0,0 +1,63 @@ +package fr.traqueur.structura.writers.registries; + +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.writer.Writer; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Singleton registry for custom {@link Writer} instances. + * Allows serializing types not natively supported by {@link fr.traqueur.structura.writers.serializer.LoadableSerializer}. + * + *
{@code
+ * CustomWriterRegistry.getInstance().register(
+ *     Component.class,
+ *     c -> MiniMessage.miniMessage().serialize(c)
+ * );
+ * }
+ */ +public class CustomWriterRegistry { + + private static final CustomWriterRegistry INSTANCE = new CustomWriterRegistry(); + + private final Map, Writer> writers = new HashMap<>(); + + private CustomWriterRegistry() {} + + public static CustomWriterRegistry getInstance() { + return INSTANCE; + } + + public void register(Class type, Writer writer) { + writers.put(type, writer); + } + + public boolean unregister(Class type) { + return writers.remove(type) != null; + } + + public boolean hasWriter(Class type) { + return writers.containsKey(type); + } + + public void clear() { + writers.clear(); + } + + @SuppressWarnings("unchecked") + /** + * Attempts to serialize {@code value} using the registered {@link Writer} for {@code type}. + * Returns {@link Optional#empty()} if no writer is registered for that type. + */ + public Optional write(Object value, Class type) { + Writer writer = writers.get(type); + if (writer == null) return Optional.empty(); + try { + return Optional.of(((Writer) writer).write(value)); + } catch (Exception e) { + throw new StructuraWriterException("Writer failed for " + type.getName() + ": " + e.getMessage(), e); + } + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java new file mode 100644 index 0000000..8e5d58e --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -0,0 +1,110 @@ +package fr.traqueur.structura.writers.serializer; + +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.mapping.FieldMapper; +import fr.traqueur.structura.references.Reference; +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.registries.CustomWriterRegistry; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Serializes a {@link Loadable} record to a YAML string. + * camelCase field names are converted to kebab-case automatically. + * Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} + * in {@link CustomWriterRegistry}. + */ +public class LoadableSerializer { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + private final FieldMapper fieldMapper; + private final Yaml yaml; + + public LoadableSerializer() { + this.fieldMapper = new FieldMapper(); + DumperOptions opts = new DumperOptions(); + opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + opts.setIndent(2); + opts.setExplicitStart(false); + this.yaml = new Yaml(opts); + } + + /** Serializes {@code config} to a block-style YAML string. */ + public String toYaml(Loadable config) { + Objects.requireNonNull(config, "config cannot be null"); + return yaml.dump(toMap(config)); + } + + private Map toMap(Object obj) { + Class clazz = obj.getClass(); + if (!clazz.isRecord()) { + throw new StructuraWriterException("Cannot serialize non-record type: " + clazz.getName()); + } + + RecordComponent[] components = clazz.getRecordComponents(); + Constructor constructor = clazz.getDeclaredConstructors()[0]; + Parameter[] parameters = constructor.getParameters(); + + Map result = new LinkedHashMap<>(); + for (int i = 0; i < components.length; i++) { + RecordComponent component = components[i]; + Parameter parameter = parameters[i]; + String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); + Object value = read(component, obj); + result.put(key, serializeValue(value, component.getGenericType())); + } + return result; + } + + private Object serializeValue(Object value, Type genericType) { + if (value == null) return null; + + if (value instanceof Reference ref) return ref.key(); + + Optional custom = CustomWriterRegistry.getInstance().write(value, value.getClass()); + if (custom.isPresent()) return custom.get(); + + if (value.getClass().isRecord()) return toMap(value); + + if (genericType instanceof ParameterizedType paramType) { + if (value instanceof List list) { + return list.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); + } + if (value instanceof Set set) { + return set.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); + } + if (value instanceof Map map) { + Map r = new LinkedHashMap<>(); + map.forEach((k, v) -> r.put(k.toString(), serializeValue(v, Object.class))); + return r; + } + } + + if (value instanceof Enum e) return fieldMapper.convertSnakeCaseToKebabCase(e.name()); + if (value instanceof LocalDate d) return d.format(DATE_FORMATTER); + if (value instanceof LocalDateTime dt) return dt.format(DATE_TIME_FORMATTER); + + return value; + } + + private Object read(RecordComponent component, Object obj) { + try { + return component.getAccessor().invoke(obj); + } catch (ReflectiveOperationException e) { + throw new StructuraWriterException("Cannot read field: " + component.getName(), e); + } + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java new file mode 100644 index 0000000..e80557d --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/writer/Writer.java @@ -0,0 +1,15 @@ +package fr.traqueur.structura.writers.writer; + +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; + +/** + * Converts a Java value to its YAML-serializable form. + * Symmetric counterpart to {@link fr.traqueur.structura.readers.Reader}. + * + * @param the Java type to serialize + */ +@FunctionalInterface +public interface Writer { + + Object write(T value) throws StructuraWriterException; +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java new file mode 100644 index 0000000..a740d5b --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java @@ -0,0 +1,61 @@ +package fr.traqueur.structura.writers; + +import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class StructuraWritersTest { + + @TempDir + Path tempDir; + + @Test + void shouldWriteAndReadBack() throws Exception { + Path file = tempDir.resolve("config.yml"); + PlainConfig original = new PlainConfig("RoundTrip", 99); + + StructuraWriters.write(file, original); + PlainConfig loaded = Structura.load(file, PlainConfig.class); + + assertEquals(original.name(), loaded.name()); + assertEquals(original.value(), loaded.value()); + } + + @Test + void shouldWriteKebabCaseKeys() throws Exception { + Path file = tempDir.resolve("config.yml"); + StructuraWriters.write(file, new CamelCaseConfig("srv", 9090)); + + String content = Files.readString(file); + assertTrue(content.contains("server-name:")); + assertTrue(content.contains("http-port:")); + } + + @Test + void saveDefaultShouldGenerateFromAnnotations() throws Exception { + Path file = tempDir.resolve("default.yml"); + StructuraWriters.saveDefault(file, SimpleDefaultConfig.class); + + SimpleDefaultConfig loaded = Structura.load(file, SimpleDefaultConfig.class); + assertEquals("Afelia", loaded.serverName()); + assertEquals(25565, loaded.port()); + assertTrue(loaded.debug()); + } + + @Test + void shouldSerializeNestedRecord() throws Exception { + Path file = tempDir.resolve("nested.yml"); + StructuraWriters.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + + String content = Files.readString(file); + assertTrue(content.contains("app-name: MyApp")); + assertTrue(content.contains("server:")); + assertTrue(content.contains("host: db.local")); + } +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java new file mode 100644 index 0000000..6deaf91 --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/factory/DefaultInstanceFactoryTest.java @@ -0,0 +1,42 @@ +package fr.traqueur.structura.writers.factory; + +import fr.traqueur.structura.writers.exceptions.StructuraWriterException; +import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultInstanceFactoryTest { + + private DefaultInstanceFactory factory; + + @BeforeEach + void setUp() { + factory = new DefaultInstanceFactory(); + } + + @Test + void shouldResolveDefaultAnnotations() { + SimpleDefaultConfig config = factory.createDefault(SimpleDefaultConfig.class); + assertEquals("Afelia", config.serverName()); + assertEquals(25565, config.port()); + assertTrue(config.debug()); + } + + @Test + void shouldCreateNestedDefaults() { + NestedDefaultConfig config = factory.createDefault(NestedDefaultConfig.class); + assertNotNull(config.server()); + assertEquals("localhost", config.server().host()); + } + + @Test + void shouldThrowForNonRecord() { + var nonRecord = new fr.traqueur.structura.api.Loadable() {}; + @SuppressWarnings("unchecked") + Class clazz = + (Class) nonRecord.getClass(); + + assertThrows(StructuraWriterException.class, () -> factory.createDefault(clazz)); + } +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java new file mode 100644 index 0000000..faac29c --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -0,0 +1,82 @@ +package fr.traqueur.structura.writers.fixtures; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.defaults.*; +import fr.traqueur.structura.api.Loadable; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class WriterTestModels { + + private WriterTestModels() {} + + public record PlainConfig(String name, int value) implements Loadable {} + + public record SimpleDefaultConfig( + @DefaultString("Afelia") String serverName, + @DefaultInt(25565) int port, + @DefaultBool(true) boolean debug + ) implements Loadable {} + + public record AllDefaultTypesConfig( + @DefaultString("hello") String str, + @DefaultInt(42) int intVal, + @DefaultLong(1000L) long longVal, + @DefaultDouble(3.14) double doubleVal, + @DefaultBool(false) boolean flag + ) implements Loadable {} + + public record ServerBlock( + @DefaultString("localhost") String host, + @DefaultInt(8080) int port + ) implements Loadable {} + + public record NestedDefaultConfig( + @DefaultString("MyApp") String appName, + ServerBlock server + ) implements Loadable {} + + public record CamelCaseConfig( + @DefaultString("test") String serverName, + @DefaultInt(9090) int httpPort + ) implements Loadable {} + + public record OptionalFieldConfig( + @DefaultString("required") String required, + @Options(optional = true) String optional + ) implements Loadable {} + + public record ConnectionBlock( + @DefaultString("db.local") String host, + @DefaultInt(5432) int port + ) implements Loadable {} + + public record InlineConfig( + @DefaultString("InlineApp") String appName, + @Options(inline = true) ConnectionBlock connection + ) implements Loadable {} + + public record CollectionConfig( + @DefaultString("test") String name, + List tags, + Set ports, + Map properties + ) implements Loadable {} + + public static final class Color { + private final int r, g, b; + + public Color(int r, int g, int b) { this.r = r; this.g = g; this.b = b; } + + public int r() { return r; } + public int g() { return g; } + public int b() { return b; } + } + + public record ColorConfig( + @DefaultString("palette") String name, + Color color + ) implements Loadable {} +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java new file mode 100644 index 0000000..43142fe --- /dev/null +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -0,0 +1,51 @@ +package fr.traqueur.structura.writers.serializer; + +import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; +import fr.traqueur.structura.writers.registries.CustomWriterRegistry; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class LoadableSerializerTest { + + private LoadableSerializer serializer; + + @BeforeEach + void setUp() { + serializer = new LoadableSerializer(); + } + + @Test + void camelCaseToKebabCase() { + String yaml = serializer.toYaml(new CamelCaseConfig("test", 9090)); + assertTrue(yaml.contains("server-name:")); + assertFalse(yaml.contains("serverName:")); + } + + @Test + void nestedRecord() { + String yaml = serializer.toYaml(new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + assertTrue(yaml.contains("app-name: MyApp")); + assertTrue(yaml.contains("server:")); + assertTrue(yaml.contains("host: db.local")); + } + + @Test + void plainList() { + String yaml = serializer.toYaml(new CollectionConfig("x", List.of("alpha", "beta"), java.util.Set.of(), java.util.Map.of())); + assertTrue(yaml.contains("alpha")); + assertTrue(yaml.contains("beta")); + } + + @Test + void customWriterInvoked() { + CustomWriterRegistry.getInstance().register(Color.class, c -> c.r() + "," + c.g() + "," + c.b()); + + String yaml = serializer.toYaml(new ColorConfig("red", new Color(255, 0, 0))); + assertTrue(yaml.contains("255,0,0") || yaml.contains("'255,0,0'")); + + CustomWriterRegistry.getInstance().unregister(Color.class); + } +} From b6f4cf3ba3118794711681bd75a6e9f21a1d4b4d Mon Sep 17 00:00:00 2001 From: el211 Date: Wed, 3 Jun 2026 23:11:06 +0200 Subject: [PATCH 02/12] chore: remove .claude/settings.local.json from tracking --- .claude/settings.local.json | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 60b3047..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(./gradlew :structura-writers:test)", - "Bash(./gradlew test:*)", - "Bash(./gradlew :structura-writers:compileJava)", - "Bash(./gradlew :structura-writers:test --info)" - ] - } -} From efdf645d2650230ee9cc8b361ecf8402084f364f Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 00:02:22 +0200 Subject: [PATCH 03/12] feat: expand serializer with polymorphic + inline support, wire SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadableSerializer now handles @Options(inline=true) record flattening, standard @Polymorphic (discriminator inside nested map), @Polymorphic(inline=true) (discriminator at parent level), and fully-inline polymorphic (@Options(inline=true) + @Polymorphic(inline=true)) - useKey and isKey serialization left as TODO for next iteration - Add StructuraWriter SPI interface in core; Structura.write() and Structura.saveDefault() discover the implementation via ServiceLoader — throws StructuraException if structura-writers is absent from classpath - Add YamlStructuraWriter as the SPI provider + META-INF/services registration - Expand WriterTestModels with Animal/DbEngine polymorphic fixtures - Add inline and polymorphic unit tests to LoadableSerializerTest --- .../fr/traqueur/structura/api/Structura.java | 68 ++++++ .../structura/api/StructuraWriter.java | 38 ++++ .../writers/YamlStructuraWriter.java | 32 +++ .../serializer/LoadableSerializer.java | 215 +++++++++++++++--- .../fr.traqueur.structura.api.StructuraWriter | 1 + .../writers/fixtures/WriterTestModels.java | 80 ++++++- .../serializer/LoadableSerializerTest.java | 100 +++++++- 7 files changed, 498 insertions(+), 36 deletions(-) create mode 100644 src/main/java/fr/traqueur/structura/api/StructuraWriter.java create mode 100644 structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java create mode 100644 structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter diff --git a/src/main/java/fr/traqueur/structura/api/Structura.java b/src/main/java/fr/traqueur/structura/api/Structura.java index 18ec2ef..71d8988 100644 --- a/src/main/java/fr/traqueur/structura/api/Structura.java +++ b/src/main/java/fr/traqueur/structura/api/Structura.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ServiceLoader; /** * Structura is a library for parsing and validating YAML configurations. @@ -40,6 +41,30 @@ public class Structura { Updater.checkUpdates(); } + /** + * Lazy, thread-safe holder for the optional {@link StructuraWriter} SPI. + * The JVM guarantees that the inner class is initialized only once, + * and only when first accessed. + */ + private static final class WriterHolder { + static final StructuraWriter INSTANCE = ServiceLoader + .load(StructuraWriter.class, Structura.class.getClassLoader()) + .findFirst() + .orElse(null); + } + + /** Returns the {@link StructuraWriter} resolved by the SPI, or throws if absent. */ + private static StructuraWriter requireWriter() { + StructuraWriter writer = WriterHolder.INSTANCE; + if (writer == null) { + throw new StructuraException( + "Structura write operations require the 'structura-writers' module on the classpath. " + + "Add it as a dependency to use Structura.write() or Structura.saveDefault()." + ); + } + return writer; + } + /** * Private constructor to prevent instantiation. * Use the {@link StructuraBuilder} to create an instance of {@link StructuraProcessor}. @@ -269,4 +294,47 @@ private static T loadInternal(Object source, Class confi throw new StructuraException("Unable to read file: " + getSourceName(source), e); } } + + // ------------------------------------------------------------------------- + // Write API (requires structura-writers on the classpath) + // ------------------------------------------------------------------------- + + /** + * Serializes {@code config} to YAML and writes it to {@code file}. + * Creates the file if it does not exist, overwrites it otherwise. + * + *

Requires the {@code structura-writers} module to be present on the classpath. + * Throws {@link StructuraException} if the module is absent.

+ * + * @param file destination path + * @param config the {@link Loadable} record to serialize + * @throws StructuraException if the writers module is absent or the write fails + */ + public static void write(Path file, Loadable config) { + requireWriter().write(file, config); + } + + /** + * Builds a default instance of {@code configClass} from its {@code @Default*} annotations, + * then writes it to {@code file}. + * + *

Does not check whether {@code file} already exists — + * that responsibility belongs to the caller:

+ *
+     * if (!Files.exists(configFile)) {
+     *     Structura.saveDefault(configFile, MyConfig.class);
+     * }
+     * 
+ * + *

Requires the {@code structura-writers} module to be present on the classpath. + * Throws {@link StructuraException} if the module is absent.

+ * + * @param file destination path + * @param configClass the record class to instantiate with default values + * @param a record type implementing {@link Loadable} + * @throws StructuraException if the writers module is absent or the write fails + */ + public static void saveDefault(Path file, Class configClass) { + requireWriter().saveDefault(file, configClass); + } } diff --git a/src/main/java/fr/traqueur/structura/api/StructuraWriter.java b/src/main/java/fr/traqueur/structura/api/StructuraWriter.java new file mode 100644 index 0000000..f684ef8 --- /dev/null +++ b/src/main/java/fr/traqueur/structura/api/StructuraWriter.java @@ -0,0 +1,38 @@ +package fr.traqueur.structura.api; + +import java.nio.file.Path; + +/** + * SPI contract for serializing {@link Loadable} records back to YAML. + * + *

Defined in the core module but not implemented here. + * The optional {@code structura-writers} module provides an implementation + * discovered at runtime via {@link java.util.ServiceLoader}.

+ * + *

If {@code structura-writers} is absent from the classpath, + * calls to {@link Structura#write} and {@link Structura#saveDefault} + * throw a {@link fr.traqueur.structura.exceptions.StructuraException}.

+ */ +public interface StructuraWriter { + + /** + * Serializes {@code config} to YAML and writes it to {@code file}. + * + * @param file destination path (created or overwritten) + * @param config the record to serialize + */ + void write(Path file, Loadable config); + + /** + * Builds a default instance of {@code configClass} from its {@code @Default*} + * annotations, then writes it to {@code file}. + * + *

Does not check whether {@code file} already exists — + * that responsibility belongs to the caller.

+ * + * @param file destination path + * @param configClass the record class to instantiate with default values + * @param a record type implementing {@link Loadable} + */ + void saveDefault(Path file, Class configClass); +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java new file mode 100644 index 0000000..73f6180 --- /dev/null +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/YamlStructuraWriter.java @@ -0,0 +1,32 @@ +package fr.traqueur.structura.writers; + +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.api.StructuraWriter; + +import java.nio.file.Path; + +/** + * SPI implementation of {@link StructuraWriter} provided by the {@code structura-writers} module. + * + *

Registered via {@code META-INF/services/fr.traqueur.structura.api.StructuraWriter} + * so that {@link fr.traqueur.structura.api.Structura} discovers it automatically through + * {@link java.util.ServiceLoader} whenever this module is on the classpath.

+ * + *

All actual logic is delegated to {@link StructuraWriters}, keeping this class + * as a thin bridge between the SPI contract and the existing public API.

+ */ +public final class YamlStructuraWriter implements StructuraWriter { + + /** No-arg constructor required by {@link java.util.ServiceLoader}. */ + public YamlStructuraWriter() {} + + @Override + public void write(Path file, Loadable config) { + StructuraWriters.write(file, config); + } + + @Override + public void saveDefault(Path file, Class configClass) { + StructuraWriters.saveDefault(file, configClass); + } +} diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 8e5d58e..1b4451f 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -1,8 +1,11 @@ package fr.traqueur.structura.writers.serializer; +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.Polymorphic; import fr.traqueur.structura.api.Loadable; import fr.traqueur.structura.mapping.FieldMapper; import fr.traqueur.structura.references.Reference; +import fr.traqueur.structura.registries.PolymorphicRegistry; import fr.traqueur.structura.writers.exceptions.StructuraWriterException; import fr.traqueur.structura.writers.registries.CustomWriterRegistry; import org.yaml.snakeyaml.DumperOptions; @@ -21,9 +24,25 @@ /** * Serializes a {@link Loadable} record to a YAML string. - * camelCase field names are converted to kebab-case automatically. - * Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} - * in {@link CustomWriterRegistry}. + * + *

Handles:

+ *
    + *
  • camelCase → kebab-case key conversion
  • + *
  • {@code @Options(inline = true)} — flattens a sub-record's fields into the parent map
  • + *
  • {@code @Polymorphic} standard — discriminator written inside the nested map
  • + *
  • {@code @Polymorphic(inline = true)} — discriminator written at the parent level
  • + *
  • {@code @Polymorphic(inline = true)} + {@code @Options(inline = true)} — fully inline: + * discriminator and all concrete fields at parent level
  • + *
+ * + *

Not yet handled (TODO):

+ *
    + *
  • {@code @Polymorphic(useKey = true)}
  • + *
  • {@code @Options(isKey = true)}
  • + *
+ * + *

Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} + * in {@link CustomWriterRegistry}.

*/ public class LoadableSerializer { @@ -31,7 +50,7 @@ public class LoadableSerializer { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; private final FieldMapper fieldMapper; - private final Yaml yaml; + private final Yaml yaml; public LoadableSerializer() { this.fieldMapper = new FieldMapper(); @@ -48,27 +67,88 @@ public String toYaml(Loadable config) { return yaml.dump(toMap(config)); } + // ------------------------------------------------------------------------- + // Core record → Map conversion + // ------------------------------------------------------------------------- + private Map toMap(Object obj) { Class clazz = obj.getClass(); if (!clazz.isRecord()) { throw new StructuraWriterException("Cannot serialize non-record type: " + clazz.getName()); } - RecordComponent[] components = clazz.getRecordComponents(); + RecordComponent[] components = clazz.getRecordComponents(); Constructor constructor = clazz.getDeclaredConstructors()[0]; - Parameter[] parameters = constructor.getParameters(); + Parameter[] parameters = constructor.getParameters(); Map result = new LinkedHashMap<>(); for (int i = 0; i < components.length; i++) { - RecordComponent component = components[i]; - Parameter parameter = parameters[i]; - String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); - Object value = read(component, obj); - result.put(key, serializeValue(value, component.getGenericType())); + contributeToMap(result, components[i], parameters[i], readField(components[i], obj)); } return result; } + // ------------------------------------------------------------------------- + // Per-field contribution + // ------------------------------------------------------------------------- + + private void contributeToMap(Map result, RecordComponent component, + Parameter parameter, Object value) { + Options opts = parameter.getAnnotation(Options.class); + boolean isInline = opts != null && opts.inline(); + Class type = component.getType(); + String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); + + if (value == null) { + result.put(key, null); + return; + } + + if (isInline) { + if (type.isRecord() && Loadable.class.isAssignableFrom(type)) { + // Flatten sub-record fields at parent level + result.putAll(toMap(value)); + } else if (isPolymorphicInterface(type)) { + Polymorphic poly = type.getAnnotation(Polymorphic.class); + if (poly.inline()) { + // Fully inline: discriminator + all concrete fields at parent level + result.put(poly.key(), lookupRegisteredName(value.getClass(), type)); + result.putAll(toMap(value)); + } else { + result.put(key, serializeValue(value, component.getGenericType())); + } + } else { + result.put(key, serializeValue(value, component.getGenericType())); + } + return; + } + + if (isPolymorphicInterface(type)) { + Polymorphic poly = type.getAnnotation(Polymorphic.class); + String discValue = lookupRegisteredName(value.getClass(), type); + + if (poly.inline()) { + // Discriminator at parent level, concrete fields nested under key + result.put(poly.key(), discValue); + result.put(key, toMap(value)); + } else { + // Standard: discriminator first, inside the nested map + // TODO: handle useKey = true + Map nested = new LinkedHashMap<>(); + nested.put(poly.key(), discValue); + nested.putAll(toMap(value)); + result.put(key, nested); + } + return; + } + + result.put(key, serializeValue(value, component.getGenericType())); + } + + // ------------------------------------------------------------------------- + // Value serialization + // ------------------------------------------------------------------------- + private Object serializeValue(Object value, Type genericType) { if (value == null) return null; @@ -77,30 +157,113 @@ private Object serializeValue(Object value, Type genericType) { Optional custom = CustomWriterRegistry.getInstance().write(value, value.getClass()); if (custom.isPresent()) return custom.get(); - if (value.getClass().isRecord()) return toMap(value); - if (genericType instanceof ParameterizedType paramType) { - if (value instanceof List list) { - return list.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); - } - if (value instanceof Set set) { - return set.stream().map(e -> serializeValue(e, Object.class)).collect(Collectors.toList()); - } - if (value instanceof Map map) { - Map r = new LinkedHashMap<>(); - map.forEach((k, v) -> r.put(k.toString(), serializeValue(v, Object.class))); - return r; - } + Type[] typeArgs = paramType.getActualTypeArguments(); + Type elemType = typeArgs.length > 0 ? typeArgs[0] : Object.class; + if (value instanceof List list) return serializeCollection(list, elemType); + if (value instanceof Set set) return serializeCollection(new ArrayList<>(set), elemType); + if (value instanceof Map map) return serializeMap(map, typeArgs); } - if (value instanceof Enum e) return fieldMapper.convertSnakeCaseToKebabCase(e.name()); + if (value.getClass().isRecord()) return toMap(value); + if (value instanceof Enum e) return fieldMapper.convertSnakeCaseToKebabCase(e.name()); if (value instanceof LocalDate d) return d.format(DATE_FORMATTER); if (value instanceof LocalDateTime dt) return dt.format(DATE_TIME_FORMATTER); return value; } - private Object read(RecordComponent component, Object obj) { + private Object serializeCollection(List elements, Type elementGenericType) { + if (elements.isEmpty()) return new ArrayList<>(); + + Class elementRawType = getRawClass(elementGenericType); + + // Polymorphic list: discriminator inside each element map + // TODO: handle useKey = true (map-keyed format) + // TODO: handle isKey records (map-keyed format) + if (isPolymorphicInterface(elementRawType)) { + Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); + List resultList = new ArrayList<>(); + for (Object elem : elements) { + Map entry = new LinkedHashMap<>(); + entry.put(poly.key(), lookupRegisteredName(elem.getClass(), elementRawType)); + entry.putAll(toMap(elem)); + resultList.add(entry); + } + return resultList; + } + + return elements.stream() + .map(e -> serializeValue(e, elementGenericType)) + .collect(Collectors.toList()); + } + + private Object serializeMap(Map map, Type[] typeArgs) { + Type valueGenericType = typeArgs.length > 1 ? typeArgs[1] : Object.class; + Class valueRawType = getRawClass(valueGenericType); + + Map result = new LinkedHashMap<>(); + + // Polymorphic map values: discriminator inside each value + // TODO: handle useKey = true + if (isPolymorphicInterface(valueRawType)) { + Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); + for (Map.Entry e : map.entrySet()) { + if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } + Map valueMap = new LinkedHashMap<>(); + valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); + valueMap.putAll(toMap(e.getValue())); + result.put(e.getKey().toString(), valueMap); + } + return result; + } + + for (Map.Entry e : map.entrySet()) { + result.put(e.getKey().toString(), serializeValue(e.getValue(), valueGenericType)); + } + return result; + } + + // ------------------------------------------------------------------------- + // Polymorphic registry reverse-lookup + // ------------------------------------------------------------------------- + + @SuppressWarnings("unchecked") + private String lookupRegisteredName(Class concreteClass, Class polymorphicInterface) { + PolymorphicRegistry registry = + (PolymorphicRegistry) PolymorphicRegistry.get( + (Class) polymorphicInterface); + + for (String name : registry.availableNames()) { + if (registry.get(name).filter(c -> c == concreteClass).isPresent()) { + return name; + } + } + throw new StructuraWriterException( + "No registered name for '" + concreteClass.getName() + + "' in polymorphic registry of '" + polymorphicInterface.getName() + "'" + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private boolean isPolymorphicInterface(Class type) { + return type.isInterface() + && Loadable.class.isAssignableFrom(type) + && type.isAnnotationPresent(Polymorphic.class); + } + + private Class getRawClass(Type type) { + return switch (type) { + case Class c -> c; + case ParameterizedType pt -> (Class) pt.getRawType(); + default -> Object.class; + }; + } + + private Object readField(RecordComponent component, Object obj) { try { return component.getAccessor().invoke(obj); } catch (ReflectiveOperationException e) { diff --git a/structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter b/structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter new file mode 100644 index 0000000..84a0434 --- /dev/null +++ b/structura-writers/src/main/resources/META-INF/services/fr.traqueur.structura.api.StructuraWriter @@ -0,0 +1 @@ +fr.traqueur.structura.writers.YamlStructuraWriter diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index faac29c..4ddc7b9 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -1,6 +1,7 @@ package fr.traqueur.structura.writers.fixtures; import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.Polymorphic; import fr.traqueur.structura.annotations.defaults.*; import fr.traqueur.structura.api.Loadable; @@ -12,6 +13,10 @@ public final class WriterTestModels { private WriterTestModels() {} + // ========================================================================= + // Basic models (from initial commit) + // ========================================================================= + public record PlainConfig(String name, int value) implements Loadable {} public record SimpleDefaultConfig( @@ -53,11 +58,6 @@ public record ConnectionBlock( @DefaultInt(5432) int port ) implements Loadable {} - public record InlineConfig( - @DefaultString("InlineApp") String appName, - @Options(inline = true) ConnectionBlock connection - ) implements Loadable {} - public record CollectionConfig( @DefaultString("test") String name, List tags, @@ -67,9 +67,7 @@ public record CollectionConfig( public static final class Color { private final int r, g, b; - public Color(int r, int g, int b) { this.r = r; this.g = g; this.b = b; } - public int r() { return r; } public int g() { return g; } public int b() { return b; } @@ -79,4 +77,72 @@ public record ColorConfig( @DefaultString("palette") String name, Color color ) implements Loadable {} + + // ========================================================================= + // @Options(inline = true) — concrete record flattening + // ========================================================================= + + public record InlineConfig( + @DefaultString("InlineApp") String appName, + @Options(inline = true) ConnectionBlock connection + ) implements Loadable {} + + // ========================================================================= + // @Polymorphic standard (discriminator inside the nested map) + // ========================================================================= + + @Polymorphic(key = "kind") + public interface Animal extends Loadable {} + + public record Dog( + @DefaultString("Rex") String name, + @DefaultString("lab") String breed + ) implements Animal {} + + public record Cat( + @DefaultString("Whiskers") String name, + @DefaultBool(true) boolean indoor + ) implements Animal {} + + public record AnimalConfig( + @DefaultString("Zoo") String appName, + Animal pet + ) implements Loadable {} + + public record AnimalListConfig( + List animals + ) implements Loadable {} + + public record AnimalMapConfig( + Map animalsByName + ) implements Loadable {} + + // ========================================================================= + // @Polymorphic(inline = true) — discriminator at parent level + // ========================================================================= + + @Polymorphic(key = "engine", inline = true) + public interface DbEngine extends Loadable {} + + public record MySQLEngine( + @DefaultString("localhost") String host, + @DefaultInt(3306) int port + ) implements DbEngine {} + + public record PostgreSQLEngine( + @DefaultString("localhost") String host, + @DefaultInt(5432) int port + ) implements DbEngine {} + + /** Discriminator at parent level, concrete fields nested under "db". */ + public record InlineDbConfig( + @DefaultString("App") String appName, + DbEngine db + ) implements Loadable {} + + /** Both inline flags — ALL fields (including discriminator) at parent level. */ + public record FullyInlineDbConfig( + @DefaultString("App") String appName, + @Options(inline = true) DbEngine db + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 43142fe..64e4463 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -1,10 +1,15 @@ package fr.traqueur.structura.writers.serializer; +import fr.traqueur.structura.api.Loadable; +import fr.traqueur.structura.registries.PolymorphicRegistry; import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; import fr.traqueur.structura.writers.registries.CustomWriterRegistry; import org.junit.jupiter.api.*; +import java.lang.reflect.Field; import java.util.List; +import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.*; @@ -12,11 +17,26 @@ class LoadableSerializerTest { private LoadableSerializer serializer; + @BeforeAll + static void setupRegistries() { + tryCreate(Animal.class, r -> { r.register("dog", Dog.class); r.register("cat", Cat.class); }); + tryCreate(DbEngine.class, r -> { r.register("mysql", MySQLEngine.class); r.register("postgres", PostgreSQLEngine.class); }); + } + + @AfterAll + static void tearDownRegistries() throws Exception { + Field f = PolymorphicRegistry.class.getDeclaredField("REGISTRIES"); + f.setAccessible(true); + ((Map) f.get(null)).clear(); + } + @BeforeEach void setUp() { serializer = new LoadableSerializer(); } + // ── Basic ──────────────────────────────────────────────────────────────── + @Test void camelCaseToKebabCase() { String yaml = serializer.toYaml(new CamelCaseConfig("test", 9090)); @@ -34,7 +54,7 @@ void nestedRecord() { @Test void plainList() { - String yaml = serializer.toYaml(new CollectionConfig("x", List.of("alpha", "beta"), java.util.Set.of(), java.util.Map.of())); + String yaml = serializer.toYaml(new CollectionConfig("x", List.of("alpha", "beta"), Set.of(), Map.of())); assertTrue(yaml.contains("alpha")); assertTrue(yaml.contains("beta")); } @@ -42,10 +62,84 @@ void plainList() { @Test void customWriterInvoked() { CustomWriterRegistry.getInstance().register(Color.class, c -> c.r() + "," + c.g() + "," + c.b()); - String yaml = serializer.toYaml(new ColorConfig("red", new Color(255, 0, 0))); assertTrue(yaml.contains("255,0,0") || yaml.contains("'255,0,0'")); - CustomWriterRegistry.getInstance().unregister(Color.class); } + + // ── @Options(inline = true) ─────────────────────────────────────────────── + + @Test + void inlineConcreteRecordFlattensFields() { + String yaml = serializer.toYaml(new InlineConfig("MyApp", new ConnectionBlock("db.local", 5432))); + + assertTrue(yaml.contains("app-name: MyApp")); + assertTrue(yaml.contains("host: db.local"), "host must be flattened to root"); + assertTrue(yaml.contains("port: 5432"), "port must be flattened to root"); + assertFalse(yaml.contains("connection:"), "'connection' key must not appear when inline"); + } + + // ── @Polymorphic standard ───────────────────────────────────────────────── + + @Test + void polymorphicDiscriminatorWrittenInsideNestedMap() { + String yaml = serializer.toYaml(new AnimalConfig("Farm", new Dog("Buddy", "poodle"))); + + assertTrue(yaml.contains("pet:"), "field key must be present"); + assertTrue(yaml.contains("kind:"), "discriminator must be inside nested map"); + assertTrue(yaml.contains("dog"), "discriminator value must be present"); + assertTrue(yaml.contains("name: Buddy")); + } + + @Test + void polymorphicList() { + String yaml = serializer.toYaml(new AnimalListConfig( + List.of(new Dog("Rex", "lab"), new Cat("Mia", true)) + )); + + assertTrue(yaml.contains("kind: dog")); + assertTrue(yaml.contains("kind: cat")); + } + + @Test + void polymorphicMap() { + String yaml = serializer.toYaml(new AnimalMapConfig( + Map.of("a", new Dog("Rex", "lab"), "b", new Cat("Luna", false)) + )); + + assertTrue(yaml.contains("kind: dog")); + assertTrue(yaml.contains("kind: cat")); + } + + // ── @Polymorphic(inline = true) ─────────────────────────────────────────── + + @Test + void inlinePolymorphicDiscriminatorAtParentLevel() { + String yaml = serializer.toYaml(new InlineDbConfig("App", new MySQLEngine("db.local", 3306))); + + assertTrue(yaml.contains("engine: mysql"), "discriminator must be at root level"); + assertTrue(yaml.contains("db:"), "concrete fields nested under 'db'"); + assertTrue(yaml.contains("host: db.local")); + } + + @Test + void fullyInlinePolymorphicFlattensEverything() { + String yaml = serializer.toYaml(new FullyInlineDbConfig("App", new MySQLEngine("db.local", 3306))); + + assertTrue(yaml.contains("engine: mysql"), "discriminator at root"); + assertTrue(yaml.contains("host: db.local"), "concrete field at root"); + assertFalse(yaml.contains("db:"), "'db' key must not appear when fully inline"); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static void tryCreate( + Class clazz, java.util.function.Consumer> cfg) { + try { + PolymorphicRegistry.get(clazz); + } catch (Exception ignored) { + PolymorphicRegistry.create(clazz, cfg); + } + } } From 1dad16bace0ad783b89fdb8d1f1e76572960cfab Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 00:38:16 +0200 Subject: [PATCH 04/12] feat: implement useKey polymorphic serialization in LoadableSerializer Resolves the useKey TODO in the serializer: when @Polymorphic(useKey = true) is declared on an interface, the discriminator value becomes the YAML key rather than a field written under a separate discriminator key. Three cases handled: - Single polymorphic field: discriminator value replaces the field name as key - List: serialized as a YAML map keyed by discriminator value - Map: concrete fields written directly under the existing map key, no redundant type discriminator injected Added ItemMeta/FoodMeta/PotionMeta fixtures and three new tests covering each of the above cases. --- .../serializer/LoadableSerializer.java | 33 +++++++++++---- .../writers/fixtures/WriterTestModels.java | 31 ++++++++++++++ .../serializer/LoadableSerializerTest.java | 42 +++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 1b4451f..2bf0c35 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -127,13 +127,15 @@ private void contributeToMap(Map result, RecordComponent compone Polymorphic poly = type.getAnnotation(Polymorphic.class); String discValue = lookupRegisteredName(value.getClass(), type); - if (poly.inline()) { + if (poly.useKey()) { + // useKey: discriminator value becomes the YAML key; field name is dropped + result.put(discValue, toMap(value)); + } else if (poly.inline()) { // Discriminator at parent level, concrete fields nested under key result.put(poly.key(), discValue); result.put(key, toMap(value)); } else { // Standard: discriminator first, inside the nested map - // TODO: handle useKey = true Map nested = new LinkedHashMap<>(); nested.put(poly.key(), discValue); nested.putAll(toMap(value)); @@ -179,10 +181,19 @@ private Object serializeCollection(List elements, Type elementGenericType) { Class elementRawType = getRawClass(elementGenericType); // Polymorphic list: discriminator inside each element map - // TODO: handle useKey = true (map-keyed format) // TODO: handle isKey records (map-keyed format) if (isPolymorphicInterface(elementRawType)) { - Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); + Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); + + if (poly.useKey()) { + // useKey list: becomes a YAML map — discriminator value is the key + Map resultMap = new LinkedHashMap<>(); + for (Object elem : elements) { + resultMap.put(lookupRegisteredName(elem.getClass(), elementRawType), toMap(elem)); + } + return resultMap; + } + List resultList = new ArrayList<>(); for (Object elem : elements) { Map entry = new LinkedHashMap<>(); @@ -205,15 +216,19 @@ private Object serializeMap(Map map, Type[] typeArgs) { Map result = new LinkedHashMap<>(); // Polymorphic map values: discriminator inside each value - // TODO: handle useKey = true if (isPolymorphicInterface(valueRawType)) { Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); for (Map.Entry e : map.entrySet()) { if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } - Map valueMap = new LinkedHashMap<>(); - valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); - valueMap.putAll(toMap(e.getValue())); - result.put(e.getKey().toString(), valueMap); + if (poly.useKey()) { + // useKey map: the map key IS the discriminator — just write concrete fields + result.put(e.getKey().toString(), toMap(e.getValue())); + } else { + Map valueMap = new LinkedHashMap<>(); + valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); + valueMap.putAll(toMap(e.getValue())); + result.put(e.getKey().toString(), valueMap); + } } return result; } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index 4ddc7b9..c6173c7 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -145,4 +145,35 @@ public record FullyInlineDbConfig( @DefaultString("App") String appName, @Options(inline = true) DbEngine db ) implements Loadable {} + + // ========================================================================= + // @Polymorphic(useKey = true) — discriminator value becomes the YAML key + // ========================================================================= + + @Polymorphic(key = "type", useKey = true) + public interface ItemMeta extends Loadable {} + + public record FoodMeta( + @DefaultInt(8) int nutrition + ) implements ItemMeta {} + + public record PotionMeta( + @DefaultString("#FF0000") String color + ) implements ItemMeta {} + + /** Single useKey polymorphic field — field name is replaced by the discriminator value. */ + public record UseKeyItemConfig( + @DefaultString("Apple") String name, + ItemMeta meta + ) implements Loadable {} + + /** List of useKey polymorphic elements — serialized as a YAML map. */ + public record UseKeyItemListConfig( + List metadata + ) implements Loadable {} + + /** Map whose values are useKey polymorphic — map key IS the discriminator. */ + public record UseKeyItemMapConfig( + Map bySlot + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 64e4463..04155f4 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -21,6 +21,7 @@ class LoadableSerializerTest { static void setupRegistries() { tryCreate(Animal.class, r -> { r.register("dog", Dog.class); r.register("cat", Cat.class); }); tryCreate(DbEngine.class, r -> { r.register("mysql", MySQLEngine.class); r.register("postgres", PostgreSQLEngine.class); }); + tryCreate(ItemMeta.class, r -> { r.register("food", FoodMeta.class); r.register("potion", PotionMeta.class); }); } @AfterAll @@ -131,6 +132,47 @@ void fullyInlinePolymorphicFlattensEverything() { assertFalse(yaml.contains("db:"), "'db' key must not appear when fully inline"); } + // ── @Polymorphic(useKey = true) ─────────────────────────────────────────── + + @Test + void useKeyPolymorphicSingleFieldDropsFieldName() { + String yaml = serializer.toYaml(new UseKeyItemConfig("Apple", new FoodMeta(10))); + + // discriminator value "food" becomes the key; field name "meta" must not appear + assertTrue(yaml.contains("food:"), "discriminator value must be the YAML key"); + assertTrue(yaml.contains("nutrition: 10")); + assertFalse(yaml.contains("meta:"), "'meta' field name must not appear with useKey"); + } + + @Test + void useKeyPolymorphicListBecomesYamlMap() { + String yaml = serializer.toYaml(new UseKeyItemListConfig( + List.of(new FoodMeta(8), new PotionMeta("#00FF00")) + )); + + assertTrue(yaml.contains("food:"), "food discriminator key missing"); + assertTrue(yaml.contains("nutrition: 8")); + assertTrue(yaml.contains("potion:"), "potion discriminator key missing"); + assertTrue(yaml.contains("#00FF00")); + // must not contain the standard list-item discriminator pattern "type: food" + assertFalse(yaml.contains("type: food"), "standard discriminator must not appear with useKey"); + } + + @Test + void useKeyPolymorphicMapOmitsExtraDiscriminator() { + String yaml = serializer.toYaml(new UseKeyItemMapConfig( + Map.of("slot1", new FoodMeta(5), "slot2", new PotionMeta("#0000FF")) + )); + + assertTrue(yaml.contains("slot1:")); + assertTrue(yaml.contains("slot2:")); + assertTrue(yaml.contains("nutrition: 5")); + assertTrue(yaml.contains("#0000FF")); + // concrete fields must NOT be wrapped with a redundant "type:" key + assertFalse(yaml.contains("type: food"), "useKey map must not embed type discriminator"); + assertFalse(yaml.contains("type: potion"), "useKey map must not embed type discriminator"); + } + // ── Helpers ─────────────────────────────────────────────────────────────── @SuppressWarnings("unchecked") From 6fde614d03e8ee5892fc9a56efc4ac97cb071155 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 09:43:30 +0200 Subject: [PATCH 05/12] feat: implement @Options(isKey=true) list serialization Records annotated with @Options(isKey=true) on one of their components are now serialized as YAML maps when they appear inside a List field. The key component's value becomes the map key, and the remaining fields are written as a nested map under it. Added Permission/PermissionConfig (single non-key field) and Route/RouteConfig (multiple non-key fields) fixtures, plus two tests verifying that the key field is correctly promoted and not duplicated inside the nested map. --- .../serializer/LoadableSerializer.java | 17 +++++++++- .../writers/fixtures/WriterTestModels.java | 27 ++++++++++++++++ .../serializer/LoadableSerializerTest.java | 32 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 2bf0c35..8bffb38 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -180,8 +180,23 @@ private Object serializeCollection(List elements, Type elementGenericType) { Class elementRawType = getRawClass(elementGenericType); + // isKey records: list becomes a YAML map keyed by the @Options(isKey=true) field value + if (elementRawType.isRecord()) { + RecordComponent keyComp = fieldMapper.findKeyComponent(elementRawType.getRecordComponents()); + if (keyComp != null) { + Map resultMap = new LinkedHashMap<>(); + for (Object elem : elements) { + Object keyValue = readField(keyComp, elem); + String mapKey = keyValue == null ? "null" : keyValue.toString(); + Map fields = toMap(elem); + fields.remove(fieldMapper.convertCamelCaseToKebabCase(keyComp.getName())); + resultMap.put(mapKey, fields); + } + return resultMap; + } + } + // Polymorphic list: discriminator inside each element map - // TODO: handle isKey records (map-keyed format) if (isPolymorphicInterface(elementRawType)) { Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index c6173c7..a1564b3 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -176,4 +176,31 @@ public record UseKeyItemListConfig( public record UseKeyItemMapConfig( Map bySlot ) implements Loadable {} + + // ========================================================================= + // @Options(isKey = true) — record field as map key in collections + // ========================================================================= + + /** Record where 'id' is the map key when serialized inside a collection. */ + public record Permission( + @Options(isKey = true) String id, + @DefaultInt(1) int level + ) implements Loadable {} + + /** Wraps a list of Permission — serialized as a map keyed by id. */ + public record PermissionConfig( + @DefaultString("MyApp") String appName, + List permissions + ) implements Loadable {} + + /** Record with two non-key fields — verifies the full nested map is preserved. */ + public record Route( + @Options(isKey = true) String path, + @DefaultString("GET") String method, + @DefaultBool(true) boolean enabled + ) implements Loadable {} + + public record RouteConfig( + List routes + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 04155f4..ca4e999 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -173,6 +173,38 @@ void useKeyPolymorphicMapOmitsExtraDiscriminator() { assertFalse(yaml.contains("type: potion"), "useKey map must not embed type discriminator"); } + // ── @Options(isKey = true) ──────────────────────────────────────────────── + + @Test + void isKeyListBecomesYamlMap() { + String yaml = serializer.toYaml(new PermissionConfig("MyApp", + List.of(new Permission("admin", 10), new Permission("user", 1)) + )); + + assertTrue(yaml.contains("admin:"), "isKey value must become the YAML key"); + assertTrue(yaml.contains("user:"), "isKey value must become the YAML key"); + assertTrue(yaml.contains("level: 10")); + assertTrue(yaml.contains("level: 1")); + // the key field itself must not appear as a nested field + assertFalse(yaml.contains("id: admin"), "'id' must not be written as a nested field"); + assertFalse(yaml.contains("id: user"), "'id' must not be written as a nested field"); + } + + @Test + void isKeyRecordWithMultipleNonKeyFieldsKeepsNestedMap() { + String yaml = serializer.toYaml(new RouteConfig( + List.of(new Route("/api/users", "GET", true), new Route("/api/admin", "POST", false)) + )); + + assertTrue(yaml.contains("/api/users:")); + assertTrue(yaml.contains("/api/admin:")); + assertTrue(yaml.contains("method: GET")); + assertTrue(yaml.contains("method: POST")); + assertTrue(yaml.contains("enabled: true")); + assertTrue(yaml.contains("enabled: false")); + assertFalse(yaml.contains("path:"), "'path' must not appear as a nested field"); + } + // ── Helpers ─────────────────────────────────────────────────────────────── @SuppressWarnings("unchecked") From a581ffd149bfd60661fac601dd2a190cf0875e8d Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 12:41:50 +0200 Subject: [PATCH 06/12] fix: omit optional null fields; add coverage for name, enum, date types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional null fields annotated with @Options(optional=true) were being written as explicit nulls in YAML. They are now silently skipped, matching the reader's behaviour of treating absent keys as optional. Also adds test coverage for three previously untested paths in the serializer: @Options(name=...) custom key override, enum → kebab-case conversion, and LocalDate/LocalDateTime ISO-8601 formatting. Corresponding fixtures (MixedOptionalConfig, CustomNameConfig, EnvConfig, ScheduleConfig) added to WriterTestModels. --- .../serializer/LoadableSerializer.java | 6 ++- .../writers/fixtures/WriterTestModels.java | 41 ++++++++++++++++ .../serializer/LoadableSerializerTest.java | 49 +++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 8bffb38..4755bf5 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -100,7 +100,10 @@ private void contributeToMap(Map result, RecordComponent compone String key = fieldMapper.getEffectiveFieldName(parameter, component.getName()); if (value == null) { - result.put(key, null); + // Optional null fields are silently omitted; non-optional nulls are written explicitly + if (opts == null || !opts.optional()) { + result.put(key, null); + } return; } @@ -301,3 +304,4 @@ private Object readField(RecordComponent component, Object obj) { } } } + diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index a1564b3..132db7e 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -5,6 +5,8 @@ import fr.traqueur.structura.annotations.defaults.*; import fr.traqueur.structura.api.Loadable; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -203,4 +205,43 @@ public record Route( public record RouteConfig( List routes ) implements Loadable {} + + // ========================================================================= + // @Options(optional = true) — null field omission + // ========================================================================= + + public record MixedOptionalConfig( + @DefaultString("required") String required, + @Options(optional = true) String maybeNull, + @Options(optional = true) Integer maybeInt + ) implements Loadable {} + + // ========================================================================= + // @Options(name = "...") — custom YAML key + // ========================================================================= + + public record CustomNameConfig( + @Options(name = "server-address") String serverAddress, + @DefaultInt(8080) int port + ) implements Loadable {} + + // ========================================================================= + // Enum serialization + // ========================================================================= + + public enum Environment { DEVELOPMENT, STAGING, PRODUCTION_READY } + + public record EnvConfig( + @DefaultString("App") String name, + Environment env + ) implements Loadable {} + + // ========================================================================= + // LocalDate / LocalDateTime serialization + // ========================================================================= + + public record ScheduleConfig( + LocalDate startDate, + LocalDateTime createdAt + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index ca4e999..6452dfd 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.*; import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Set; @@ -68,6 +70,53 @@ void customWriterInvoked() { CustomWriterRegistry.getInstance().unregister(Color.class); } + // ── @Options(optional = true) ──────────────────────────────────────────── + + @Test + void optionalNullFieldIsOmitted() { + String yaml = serializer.toYaml(new MixedOptionalConfig("hello", null, null)); + assertTrue(yaml.contains("required: hello")); + assertFalse(yaml.contains("maybe-null:"), "null optional field must not appear in YAML"); + assertFalse(yaml.contains("maybe-int:"), "null optional field must not appear in YAML"); + } + + @Test + void optionalPresentFieldIsWritten() { + String yaml = serializer.toYaml(new MixedOptionalConfig("hello", "world", 42)); + assertTrue(yaml.contains("maybe-null: world")); + assertTrue(yaml.contains("maybe-int: 42")); + } + + // ── @Options(name = "...") ──────────────────────────────────────────────── + + @Test + void customFieldNameOverridesDefault() { + String yaml = serializer.toYaml(new CustomNameConfig("192.168.1.1", 9090)); + assertTrue(yaml.contains("server-address: 192.168.1.1"), "custom name must be used as YAML key"); + assertFalse(yaml.contains("server-address-field:")); + assertFalse(yaml.contains("serverAddress:"), "camelCase field name must not appear"); + } + + // ── Enum serialization ──────────────────────────────────────────────────── + + @Test + void enumFieldIsConvertedToKebabCase() { + String yaml = serializer.toYaml(new EnvConfig("App", Environment.PRODUCTION_READY)); + assertTrue(yaml.contains("production-ready"), "enum value must be written in kebab-case"); + assertFalse(yaml.contains("PRODUCTION_READY"), "raw enum name must not appear"); + } + + // ── LocalDate / LocalDateTime serialization ─────────────────────────────── + + @Test + void localDateIsIsoFormatted() { + String yaml = serializer.toYaml(new ScheduleConfig( + LocalDate.of(2024, 6, 15), LocalDateTime.of(2024, 6, 15, 10, 30, 0) + )); + assertTrue(yaml.contains("2024-06-15"), "LocalDate must use ISO-8601 date format"); + assertTrue(yaml.contains("2024-06-15T10:30"), "LocalDateTime must use ISO-8601 datetime format"); + } + // ── @Options(inline = true) ─────────────────────────────────────────────── @Test From f2b398d2bef8533ec3e488a543e00ea63224be1f Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:11:29 +0200 Subject: [PATCH 07/12] fix: isKey in maps, parent dirs on write; round-trip and coverage tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadableSerializer: handle @Options(isKey=true) inside Map values — the key component is stripped from each nested field map, symmetric with the existing List behaviour. Update class Javadoc to reflect all currently supported features (was still listing useKey/isKey as TODO). - StructuraWriters.write(): create parent directories before writing so callers can pass deep paths (e.g. plugins/foo/config.yml) without having to create the directory tree themselves. - StructuraWritersTest: add polymorphic standard + inline round-trip tests (write then Structura.load and assert concrete type), a test verifying parent directory creation, and saveDefault coverage for optional fields with no @Default* annotation. - WriterTestModels: add Endpoint/EndpointMapConfig for Map isKey tests and OptionalOnlyConfig for the saveDefault optional-omission test. --- .../structura/writers/StructuraWriters.java | 2 + .../serializer/LoadableSerializer.java | 28 ++++-- .../writers/StructuraWritersTest.java | 89 +++++++++++++++++-- .../writers/fixtures/WriterTestModels.java | 24 +++++ .../serializer/LoadableSerializerTest.java | 15 ++++ 5 files changed, 146 insertions(+), 12 deletions(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java index 484375d..4f7874b 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/StructuraWriters.java @@ -35,6 +35,8 @@ public static void write(Path file, Loadable config) { String yaml = SERIALIZER.toYaml(config); try { + Path parent = file.getParent(); + if (parent != null) Files.createDirectories(parent); Files.writeString(file, yaml, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); } catch (IOException e) { throw new StructuraWriterException("Failed to write configuration to: " + file.toAbsolutePath(), e); diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 4755bf5..1f96ff2 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -29,16 +29,17 @@ *
    *
  • camelCase → kebab-case key conversion
  • *
  • {@code @Options(inline = true)} — flattens a sub-record's fields into the parent map
  • + *
  • {@code @Options(optional = true)} — null fields are silently omitted
  • + *
  • {@code @Options(name = "...")} — overrides the YAML key for a field
  • + *
  • {@code @Options(isKey = true)} — record field used as map key in List and Map values
  • *
  • {@code @Polymorphic} standard — discriminator written inside the nested map
  • *
  • {@code @Polymorphic(inline = true)} — discriminator written at the parent level
  • *
  • {@code @Polymorphic(inline = true)} + {@code @Options(inline = true)} — fully inline: * discriminator and all concrete fields at parent level
  • - *
- * - *

Not yet handled (TODO):

- *
    - *
  • {@code @Polymorphic(useKey = true)}
  • - *
  • {@code @Options(isKey = true)}
  • + *
  • {@code @Polymorphic(useKey = true)} — discriminator value becomes the YAML key
  • + *
  • {@link java.time.LocalDate} / {@link java.time.LocalDateTime} — ISO-8601 strings
  • + *
  • {@link java.lang.Enum} — snake_case name converted to kebab-case
  • + *
  • {@link fr.traqueur.structura.references.Reference} — serialized as its key string
  • *
* *

Custom types can be handled by registering a {@link fr.traqueur.structura.writers.writer.Writer} @@ -233,6 +234,21 @@ private Object serializeMap(Map map, Type[] typeArgs) { Map result = new LinkedHashMap<>(); + // isKey records as map values: strip the key component from each nested map + if (valueRawType.isRecord()) { + RecordComponent keyComp = fieldMapper.findKeyComponent(valueRawType.getRecordComponents()); + if (keyComp != null) { + String keyField = fieldMapper.convertCamelCaseToKebabCase(keyComp.getName()); + for (Map.Entry e : map.entrySet()) { + if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } + Map fields = toMap(e.getValue()); + fields.remove(keyField); + result.put(e.getKey().toString(), fields); + } + return result; + } + } + // Polymorphic map values: discriminator inside each value if (isPolymorphicInterface(valueRawType)) { Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java index a740d5b..05dd5ef 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/StructuraWritersTest.java @@ -1,12 +1,15 @@ package fr.traqueur.structura.writers; import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.registries.PolymorphicRegistry; import fr.traqueur.structura.writers.fixtures.WriterTestModels.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.io.TempDir; +import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -15,6 +18,21 @@ class StructuraWritersTest { @TempDir Path tempDir; + @BeforeAll + static void setupRegistries() { + tryCreate(Animal.class, r -> { r.register("dog", Dog.class); r.register("cat", Cat.class); }); + tryCreate(DbEngine.class, r -> { r.register("mysql", MySQLEngine.class); r.register("postgres", PostgreSQLEngine.class); }); + } + + @AfterAll + static void tearDownRegistries() throws Exception { + Field f = PolymorphicRegistry.class.getDeclaredField("REGISTRIES"); + f.setAccessible(true); + ((Map) f.get(null)).clear(); + } + + // ── Basic write / read-back ─────────────────────────────────────────────── + @Test void shouldWriteAndReadBack() throws Exception { Path file = tempDir.resolve("config.yml"); @@ -37,6 +55,31 @@ void shouldWriteKebabCaseKeys() throws Exception { assertTrue(content.contains("http-port:")); } + @Test + void shouldSerializeNestedRecord() throws Exception { + Path file = tempDir.resolve("nested.yml"); + StructuraWriters.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + + String content = Files.readString(file); + assertTrue(content.contains("app-name: MyApp")); + assertTrue(content.contains("server:")); + assertTrue(content.contains("host: db.local")); + } + + // ── Parent directory creation ───────────────────────────────────────────── + + @Test + void writeCreatesParentDirectoriesIfAbsent() throws Exception { + Path file = tempDir.resolve("a/b/c/config.yml"); + assertFalse(Files.exists(file.getParent()), "parent must not exist before write"); + + StructuraWriters.write(file, new PlainConfig("deep", 1)); + + assertTrue(Files.exists(file), "file must be created including parent dirs"); + } + + // ── saveDefault ─────────────────────────────────────────────────────────── + @Test void saveDefaultShouldGenerateFromAnnotations() throws Exception { Path file = tempDir.resolve("default.yml"); @@ -49,13 +92,47 @@ void saveDefaultShouldGenerateFromAnnotations() throws Exception { } @Test - void shouldSerializeNestedRecord() throws Exception { - Path file = tempDir.resolve("nested.yml"); - StructuraWriters.write(file, new NestedDefaultConfig("MyApp", new ServerBlock("db.local", 5432))); + void saveDefaultOmitsOptionalFieldsWithNoDefault() throws Exception { + Path file = tempDir.resolve("optional.yml"); + StructuraWriters.saveDefault(file, OptionalOnlyConfig.class); String content = Files.readString(file); - assertTrue(content.contains("app-name: MyApp")); - assertTrue(content.contains("server:")); - assertTrue(content.contains("host: db.local")); + assertTrue(content.contains("required:")); + assertFalse(content.contains("never-default:"), "optional field with no default must be absent"); + } + + // ── Polymorphic round-trip ──────────────────────────────────────────────── + + @Test + void polymorphicStandardRoundTrip() throws Exception { + Path file = tempDir.resolve("animal.yml"); + StructuraWriters.write(file, new AnimalConfig("Farm", new Dog("Buddy", "poodle"))); + + AnimalConfig loaded = Structura.load(file, AnimalConfig.class); + assertInstanceOf(Dog.class, loaded.pet()); + assertEquals("Buddy", ((Dog) loaded.pet()).name()); + assertEquals("poodle", ((Dog) loaded.pet()).breed()); + } + + @Test + void polymorphicInlineRoundTrip() throws Exception { + Path file = tempDir.resolve("db.yml"); + StructuraWriters.write(file, new InlineDbConfig("App", new MySQLEngine("db.local", 3306))); + + InlineDbConfig loaded = Structura.load(file, InlineDbConfig.class); + assertInstanceOf(MySQLEngine.class, loaded.db()); + assertEquals("db.local", ((MySQLEngine) loaded.db()).host()); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static void tryCreate( + Class clazz, java.util.function.Consumer> cfg) { + try { + PolymorphicRegistry.get(clazz); + } catch (Exception ignored) { + PolymorphicRegistry.create(clazz, cfg); + } } } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index 132db7e..ab7c872 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -244,4 +244,28 @@ public record ScheduleConfig( LocalDate startDate, LocalDateTime createdAt ) implements Loadable {} + + // ========================================================================= + // @Options(isKey = true) inside a Map value + // ========================================================================= + + /** Same as Route but used to test Map serialization. */ + public record Endpoint( + @Options(isKey = true) String path, + @DefaultString("GET") String method, + @DefaultInt(200) int statusCode + ) implements Loadable {} + + public record EndpointMapConfig( + Map endpoints + ) implements Loadable {} + + // ========================================================================= + // saveDefault with optional fields (no @Default* annotation) + // ========================================================================= + + public record OptionalOnlyConfig( + @DefaultString("required") String required, + @Options(optional = true) String neverDefault + ) implements Loadable {} } diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 6452dfd..7a3b5b3 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -239,6 +239,21 @@ void isKeyListBecomesYamlMap() { assertFalse(yaml.contains("id: user"), "'id' must not be written as a nested field"); } + @Test + void isKeyInsideMapValueStripsKeyField() { + String yaml = serializer.toYaml(new EndpointMapConfig(Map.of( + "/health", new Endpoint("/health", "GET", 200), + "/login", new Endpoint("/login", "POST", 201) + ))); + + assertTrue(yaml.contains("/health:")); + assertTrue(yaml.contains("/login:")); + assertTrue(yaml.contains("method: GET")); + assertTrue(yaml.contains("method: POST")); + assertTrue(yaml.contains("status-code: 200")); + assertFalse(yaml.contains("path:"), "'path' must not appear as a nested field"); + } + @Test void isKeyRecordWithMultipleNonKeyFieldsKeepsNestedMap() { String yaml = serializer.toYaml(new RouteConfig( From 8f710744c67ef42a549b7f95aa061fe9fc593534 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:26:30 +0200 Subject: [PATCH 08/12] refactor: centralize isKey logic in toMap(); add complex key support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move @Options(isKey=true) handling from serializeCollection/serializeMap into toMap() itself via toMapWithKeyComponent(). This mirrors the approach in the parallel implementation and fixes two gaps: - Simple key (String/primitive): toMap() now returns { keyValue: {fields} } so serializeCollection() just putAll(), and serializeMap() extracts the inner value — no more manual field stripping spread across helpers. - Complex key (record type as isKey field): key sub-record fields are flattened alongside other fields at the same level. Lists of complex-key records are serialized as YAML lists (not maps) since there is no unique scalar key to distinguish entries. Also adds isPolymorphicWithUseKey() and findComponentIndex() helpers, improves the lookupRegisteredName() error message, and covers the new complex key cases with two tests + ServerCoordinates/ComplexKeyEntry fixtures. --- .../serializer/LoadableSerializer.java | 199 ++++++++++++++---- .../writers/fixtures/WriterTestModels.java | 23 +- .../serializer/LoadableSerializerTest.java | 34 +++ 3 files changed, 209 insertions(+), 47 deletions(-) diff --git a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java index 1f96ff2..d73a053 100644 --- a/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java +++ b/structura-writers/src/main/java/fr/traqueur/structura/writers/serializer/LoadableSerializer.java @@ -25,13 +25,14 @@ /** * Serializes a {@link Loadable} record to a YAML string. * - *

Handles:

+ *

Supports the full symmetric set of features that the Structura reader understands:

*
    *
  • camelCase → kebab-case key conversion
  • *
  • {@code @Options(inline = true)} — flattens a sub-record's fields into the parent map
  • *
  • {@code @Options(optional = true)} — null fields are silently omitted
  • *
  • {@code @Options(name = "...")} — overrides the YAML key for a field
  • - *
  • {@code @Options(isKey = true)} — record field used as map key in List and Map values
  • + *
  • {@code @Options(isKey = true)} simple — record serialized as {@code {keyValue: {otherFields}}}
  • + *
  • {@code @Options(isKey = true)} complex — key sub-record's fields flattened at same level
  • *
  • {@code @Polymorphic} standard — discriminator written inside the nested map
  • *
  • {@code @Polymorphic(inline = true)} — discriminator written at the parent level
  • *
  • {@code @Polymorphic(inline = true)} + {@code @Options(inline = true)} — fully inline: @@ -72,6 +73,12 @@ public String toYaml(Loadable config) { // Core record → Map conversion // ------------------------------------------------------------------------- + /** + * Converts any record to a {@code Map} that SnakeYAML can dump. + * + *

    When the record has a component marked {@code @Options(isKey = true)}, delegates to + * {@link #toMapWithKeyComponent} which handles both simple-key and complex-key variants.

    + */ private Map toMap(Object obj) { Class clazz = obj.getClass(); if (!clazz.isRecord()) { @@ -82,6 +89,11 @@ private Map toMap(Object obj) { Constructor constructor = clazz.getDeclaredConstructors()[0]; Parameter[] parameters = constructor.getParameters(); + RecordComponent keyComponent = fieldMapper.findKeyComponent(components); + if (keyComponent != null) { + return toMapWithKeyComponent(obj, components, parameters, keyComponent); + } + Map result = new LinkedHashMap<>(); for (int i = 0; i < components.length; i++) { contributeToMap(result, components[i], parameters[i], readField(components[i], obj)); @@ -89,10 +101,67 @@ private Map toMap(Object obj) { return result; } + /** + * Serializes a record that has a component marked {@code @Options(isKey = true)}. + * + *
      + *
    • Simple key (String / primitive): returns {@code {keyValue: {otherFields}}}
    • + *
    • Complex key (record type): flattens the key sub-record's fields alongside the + * other fields at the same level
    • + *
    + */ + private Map toMapWithKeyComponent(Object obj, RecordComponent[] components, + Parameter[] parameters, + RecordComponent keyComponent) { + Class keyType = keyComponent.getType(); + + if (keyType.isRecord()) { + // Complex key: flatten key-record fields alongside other fields + Map result = new LinkedHashMap<>(); + for (int i = 0; i < components.length; i++) { + RecordComponent comp = components[i]; + Object value = readField(comp, obj); + if (comp.getName().equals(keyComponent.getName())) { + if (value != null) result.putAll(toMap(value)); + } else { + contributeToMap(result, comp, parameters[i], value); + } + } + return result; + } + + // Simple key: { keyValue → { otherFields } } + Object keyValue = readField(keyComponent, obj); + Map otherFields = new LinkedHashMap<>(); + int keyIdx = findComponentIndex(components, keyComponent); + for (int i = 0; i < components.length; i++) { + if (i != keyIdx) { + contributeToMap(otherFields, components[i], parameters[i], readField(components[i], obj)); + } + } + + Map result = new LinkedHashMap<>(); + result.put(keyValue != null ? keyValue.toString() : "", otherFields.isEmpty() ? null : otherFields); + return result; + } + // ------------------------------------------------------------------------- // Per-field contribution // ------------------------------------------------------------------------- + /** + * Adds the serialized representation of a single record component to {@code result}. + * + *

    Dispatch (in priority order):

    + *
      + *
    1. {@code @Options(inline=true)} + concrete Loadable record → flatten sub-fields
    2. + *
    3. {@code @Options(inline=true)} + {@code @Polymorphic(inline=true)} → fully inline
    4. + *
    5. {@code @Polymorphic(useKey=true)} → discriminator value becomes the YAML key
    6. + *
    7. {@code @Polymorphic(inline=true)} → discriminator at parent, fields under key
    8. + *
    9. {@code @Polymorphic} standard → discriminator inside nested map
    10. + *
    11. Everything else → normal key/value
    12. + *
    + */ private void contributeToMap(Map result, RecordComponent component, Parameter parameter, Object value) { Options opts = parameter.getAnnotation(Options.class); @@ -108,9 +177,9 @@ private void contributeToMap(Map result, RecordComponent compone return; } + // ── @Options(inline = true) ────────────────────────────────────────── if (isInline) { if (type.isRecord() && Loadable.class.isAssignableFrom(type)) { - // Flatten sub-record fields at parent level result.putAll(toMap(value)); } else if (isPolymorphicInterface(type)) { Polymorphic poly = type.getAnnotation(Polymorphic.class); @@ -127,12 +196,13 @@ private void contributeToMap(Map result, RecordComponent compone return; } + // ── Polymorphic interface ──────────────────────────────────────────── if (isPolymorphicInterface(type)) { Polymorphic poly = type.getAnnotation(Polymorphic.class); String discValue = lookupRegisteredName(value.getClass(), type); if (poly.useKey()) { - // useKey: discriminator value becomes the YAML key; field name is dropped + // Discriminator value becomes the YAML key; field name is dropped result.put(discValue, toMap(value)); } else if (poly.inline()) { // Discriminator at parent level, concrete fields nested under key @@ -179,40 +249,53 @@ private Object serializeValue(Object value, Type genericType) { return value; } + /** + * Serializes a {@code List} or (flattened) {@code Set}. + * + *
      + *
    • {@code @Polymorphic(useKey=true)} elements → map keyed by registered name
    • + *
    • {@code @Options(isKey=true)} record elements → {@code toMap()} already wraps each + * element as {@code {keyValue: {otherFields}}}; results are merged into one map
    • + *
    • Standard polymorphic elements → list with discriminator inside each entry
    • + *
    • Otherwise → plain list
    • + *
    + */ private Object serializeCollection(List elements, Type elementGenericType) { if (elements.isEmpty()) return new ArrayList<>(); Class elementRawType = getRawClass(elementGenericType); - // isKey records: list becomes a YAML map keyed by the @Options(isKey=true) field value + // useKey polymorphic list → map { registeredName: { fields } } + if (isPolymorphicWithUseKey(elementRawType)) { + Map resultMap = new LinkedHashMap<>(); + for (Object elem : elements) { + resultMap.put(lookupRegisteredName(elem.getClass(), elementRawType), toMap(elem)); + } + return resultMap; + } + + // isKey record list if (elementRawType.isRecord()) { RecordComponent keyComp = fieldMapper.findKeyComponent(elementRawType.getRecordComponents()); if (keyComp != null) { + if (keyComp.getType().isRecord()) { + // Complex key: toMap() flattens the key sub-record; serialize as a YAML list + return elements.stream() + .map(e -> toMap(e)) + .collect(Collectors.toList()); + } + // Simple key: toMap() returns { keyValue: { otherFields } }; merge into one map Map resultMap = new LinkedHashMap<>(); for (Object elem : elements) { - Object keyValue = readField(keyComp, elem); - String mapKey = keyValue == null ? "null" : keyValue.toString(); - Map fields = toMap(elem); - fields.remove(fieldMapper.convertCamelCaseToKebabCase(keyComp.getName())); - resultMap.put(mapKey, fields); + resultMap.putAll(toMap(elem)); } return resultMap; } } - // Polymorphic list: discriminator inside each element map + // Standard polymorphic list → list with discriminator inside each entry if (isPolymorphicInterface(elementRawType)) { - Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); - - if (poly.useKey()) { - // useKey list: becomes a YAML map — discriminator value is the key - Map resultMap = new LinkedHashMap<>(); - for (Object elem : elements) { - resultMap.put(lookupRegisteredName(elem.getClass(), elementRawType), toMap(elem)); - } - return resultMap; - } - + Polymorphic poly = elementRawType.getAnnotation(Polymorphic.class); List resultList = new ArrayList<>(); for (Object elem : elements) { Map entry = new LinkedHashMap<>(); @@ -228,41 +311,53 @@ private Object serializeCollection(List elements, Type elementGenericType) { .collect(Collectors.toList()); } + /** + * Serializes a {@code Map}. + * + *
      + *
    • {@code @Polymorphic(useKey=true)} values → outer key is the discriminator, + * just write concrete fields (no extra discriminator key)
    • + *
    • {@code @Options(isKey=true)} record values → {@code toMap()} wraps as + * {@code {keyValue: {otherFields}}}; extract the value so the outer map key is kept
    • + *
    • Standard polymorphic values → discriminator inside each value map
    • + *
    • Otherwise → normal map
    • + *
    + */ private Object serializeMap(Map map, Type[] typeArgs) { Type valueGenericType = typeArgs.length > 1 ? typeArgs[1] : Object.class; Class valueRawType = getRawClass(valueGenericType); Map result = new LinkedHashMap<>(); - // isKey records as map values: strip the key component from each nested map - if (valueRawType.isRecord()) { - RecordComponent keyComp = fieldMapper.findKeyComponent(valueRawType.getRecordComponents()); - if (keyComp != null) { - String keyField = fieldMapper.convertCamelCaseToKebabCase(keyComp.getName()); - for (Map.Entry e : map.entrySet()) { - if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } - Map fields = toMap(e.getValue()); - fields.remove(keyField); - result.put(e.getKey().toString(), fields); - } - return result; + // useKey map: outer key IS the discriminator — write concrete fields only + if (isPolymorphicWithUseKey(valueRawType)) { + for (Map.Entry e : map.entrySet()) { + result.put(e.getKey().toString(), e.getValue() != null ? toMap(e.getValue()) : null); + } + return result; + } + + // isKey record values: toMap() wraps as { keyValue: { otherFields } }; + // the outer map key is already provided, so we extract the inner value + if (valueRawType.isRecord() + && fieldMapper.findKeyComponent(valueRawType.getRecordComponents()) != null) { + for (Map.Entry e : map.entrySet()) { + if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } + Map wrapped = toMap(e.getValue()); + result.put(e.getKey().toString(), wrapped.values().iterator().next()); } + return result; } - // Polymorphic map values: discriminator inside each value + // Standard polymorphic map values: discriminator inside each value if (isPolymorphicInterface(valueRawType)) { Polymorphic poly = valueRawType.getAnnotation(Polymorphic.class); for (Map.Entry e : map.entrySet()) { if (e.getValue() == null) { result.put(e.getKey().toString(), null); continue; } - if (poly.useKey()) { - // useKey map: the map key IS the discriminator — just write concrete fields - result.put(e.getKey().toString(), toMap(e.getValue())); - } else { - Map valueMap = new LinkedHashMap<>(); - valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); - valueMap.putAll(toMap(e.getValue())); - result.put(e.getKey().toString(), valueMap); - } + Map valueMap = new LinkedHashMap<>(); + valueMap.put(poly.key(), lookupRegisteredName(e.getValue().getClass(), valueRawType)); + valueMap.putAll(toMap(e.getValue())); + result.put(e.getKey().toString(), valueMap); } return result; } @@ -290,7 +385,9 @@ private String lookupRegisteredName(Class concreteClass, Class polymorphic } throw new StructuraWriterException( "No registered name for '" + concreteClass.getName() + - "' in polymorphic registry of '" + polymorphicInterface.getName() + "'" + "' in polymorphic registry of '" + polymorphicInterface.getName() + "'. " + + "Register it via PolymorphicRegistry.get(" + polymorphicInterface.getSimpleName() + + ".class).register(\"name\", " + concreteClass.getSimpleName() + ".class)" ); } @@ -304,6 +401,10 @@ private boolean isPolymorphicInterface(Class type) { && type.isAnnotationPresent(Polymorphic.class); } + private boolean isPolymorphicWithUseKey(Class type) { + return isPolymorphicInterface(type) && type.getAnnotation(Polymorphic.class).useKey(); + } + private Class getRawClass(Type type) { return switch (type) { case Class c -> c; @@ -319,5 +420,11 @@ private Object readField(RecordComponent component, Object obj) { throw new StructuraWriterException("Cannot read field: " + component.getName(), e); } } -} + private int findComponentIndex(RecordComponent[] components, RecordComponent target) { + for (int i = 0; i < components.length; i++) { + if (components[i].getName().equals(target.getName())) return i; + } + return -1; + } +} diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java index ab7c872..b215050 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/fixtures/WriterTestModels.java @@ -180,7 +180,7 @@ public record UseKeyItemMapConfig( ) implements Loadable {} // ========================================================================= - // @Options(isKey = true) — record field as map key in collections + // @Options(isKey = true) simple — String/primitive key // ========================================================================= /** Record where 'id' is the map key when serialized inside a collection. */ @@ -206,6 +206,27 @@ public record RouteConfig( List routes ) implements Loadable {} + // ========================================================================= + // @Options(isKey = true) complex — record type as key (fields flattened) + // ========================================================================= + + /** Sub-record used as a complex key — its fields are flattened at the same level. */ + public record ServerCoordinates( + @DefaultString("localhost") String host, + @DefaultInt(8080) int port + ) implements Loadable {} + + /** Record whose key component is itself a record — coords fields are flattened. */ + public record ComplexKeyEntry( + @Options(isKey = true) ServerCoordinates coords, + @DefaultString("default") String label, + @DefaultBool(false) boolean active + ) implements Loadable {} + + public record ComplexKeyListConfig( + List entries + ) implements Loadable {} + // ========================================================================= // @Options(optional = true) — null field omission // ========================================================================= diff --git a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java index 7a3b5b3..660e643 100644 --- a/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java +++ b/structura-writers/src/test/java/fr/traqueur/structura/writers/serializer/LoadableSerializerTest.java @@ -239,6 +239,40 @@ void isKeyListBecomesYamlMap() { assertFalse(yaml.contains("id: user"), "'id' must not be written as a nested field"); } + @Test + void isKeyComplexKeyRecordFlattensSubRecordFields() { + String yaml = serializer.toYaml(new ComplexKeyEntry( + new ServerCoordinates("db.example.com", 5432), "primary", true + )); + + assertTrue(yaml.contains("host: db.example.com"), "host must be at root level"); + assertTrue(yaml.contains("port: 5432"), "port must be at root level"); + assertTrue(yaml.contains("label: primary")); + assertTrue(yaml.contains("active: true")); + assertFalse(yaml.contains("coords:"), "coords field name must not appear when flattened"); + } + + @Test + void isKeyComplexKeyListProducesYamlListWithFlattenedFields() { + String yaml = serializer.toYaml(new ComplexKeyListConfig(List.of( + new ComplexKeyEntry(new ServerCoordinates("primary.db", 5432), "primary", true), + new ComplexKeyEntry(new ServerCoordinates("replica.db", 5433), "replica", false) + ))); + + // Each entry is a YAML list item with the key sub-record flattened at item level + assertTrue(yaml.contains("host: primary.db")); + assertTrue(yaml.contains("host: replica.db")); + assertTrue(yaml.contains("port: 5432")); + assertTrue(yaml.contains("port: 5433")); + assertTrue(yaml.contains("label: primary")); + assertTrue(yaml.contains("label: replica")); + // coords key must not appear (sub-record is flattened) + assertFalse(yaml.contains("coords:")); + // Must be a list (dashes), not a map — complex keys cannot act as unique map keys + assertTrue(yaml.contains("- host:") || yaml.contains("-\n") || yaml.contains("- "), + "output must be a YAML list, not a map"); + } + @Test void isKeyInsideMapValueStripsKeyField() { String yaml = serializer.toYaml(new EndpointMapConfig(Map.of( From 89f240925e3797b739f429cd2dc84989c9ae47a6 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:42:02 +0200 Subject: [PATCH 09/12] feat: add McTest example plugin using Structura + structura-writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the full read/write cycle in a real Paper plugin: - MainConfig — standard config with @Default* and an optional field - DatabaseConfig — @Polymorphic(inline=true) driver (MySQL / SQLite) - RewardsConfig — List with @Polymorphic (item/money/command) On first enable, saveDefault() generates all three config files from annotations. /mctest reload re-reads from disk, /mctest save writes the current in-memory state back, /mctest info prints all values in chat. PolymorphicRegistry setup is done once in onEnable() before any load. --- McTest/build.gradle.kts | 44 +++++ .../java/fr/traqueur/mctest/McTestPlugin.java | 155 ++++++++++++++++++ .../mctest/config/DatabaseConfig.java | 50 ++++++ .../fr/traqueur/mctest/config/MainConfig.java | 26 +++ .../traqueur/mctest/config/RewardsConfig.java | 54 ++++++ McTest/src/main/resources/plugin.yml | 12 ++ settings.gradle.kts | 3 +- 7 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 McTest/build.gradle.kts create mode 100644 McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java create mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java create mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java create mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java create mode 100644 McTest/src/main/resources/plugin.yml diff --git a/McTest/build.gradle.kts b/McTest/build.gradle.kts new file mode 100644 index 0000000..0c00dd5 --- /dev/null +++ b/McTest/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("java") + id("com.gradleup.shadow") version "9.0.0-beta11" +} + +group = "fr.traqueur" +version = rootProject.property("version") as String + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +dependencies { + // Paper API — provided at runtime by the server + compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") + // SnakeYAML is bundled by Paper, so compileOnly here too + compileOnly("org.yaml:snakeyaml:2.4") + + // Structura core + writers — shaded into the jar + implementation(project(":")) + implementation(project(":structura-writers")) +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to project.version) + } +} + +tasks.shadowJar { + archiveClassifier.set("") + // Relocate Structura so it doesn't conflict if another plugin uses it + relocate("fr.traqueur.structura", "fr.traqueur.mctest.libs.structura") +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java new file mode 100644 index 0000000..516f396 --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java @@ -0,0 +1,155 @@ +package fr.traqueur.mctest; + +import fr.traqueur.mctest.config.DatabaseConfig; +import fr.traqueur.mctest.config.DatabaseConfig.DatabaseDriver; +import fr.traqueur.mctest.config.DatabaseConfig.MySQLDriver; +import fr.traqueur.mctest.config.DatabaseConfig.SQLiteDriver; +import fr.traqueur.mctest.config.MainConfig; +import fr.traqueur.mctest.config.RewardsConfig; +import fr.traqueur.mctest.config.RewardsConfig.CommandReward; +import fr.traqueur.mctest.config.RewardsConfig.ItemReward; +import fr.traqueur.mctest.config.RewardsConfig.MoneyReward; +import fr.traqueur.mctest.config.RewardsConfig.Reward; +import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.registries.PolymorphicRegistry; +import fr.traqueur.structura.writers.StructuraWriters; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.java.JavaPlugin; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public final class McTestPlugin extends JavaPlugin { + + private MainConfig mainConfig; + private DatabaseConfig databaseConfig; + private RewardsConfig rewardsConfig; + + @Override + public void onEnable() { + setupRegistries(); + loadConfigs(); + getLogger().info("McTest enabled — plugin name: " + mainConfig.pluginName()); + } + + @Override + public void onDisable() { + getLogger().info("McTest disabled."); + } + + // ------------------------------------------------------------------------- + // Config + // ------------------------------------------------------------------------- + + private void setupRegistries() { + // Register all polymorphic types before loading any config that uses them + PolymorphicRegistry.create(DatabaseDriver.class, r -> { + r.register("mysql", MySQLDriver.class); + r.register("sqlite", SQLiteDriver.class); + }); + + PolymorphicRegistry.create(Reward.class, r -> { + r.register("item", ItemReward.class); + r.register("money", MoneyReward.class); + r.register("command", CommandReward.class); + }); + } + + private void loadConfigs() { + Path folder = getDataFolder().toPath(); + + mainConfig = loadOrDefault(folder.resolve("config.yml"), MainConfig.class); + databaseConfig = loadOrDefault(folder.resolve("database.yml"), DatabaseConfig.class); + rewardsConfig = loadOrDefault(folder.resolve("rewards.yml"), RewardsConfig.class); + } + + /** + * Loads a config file if it exists, otherwise generates it from @Default* annotations. + * This is the standard Structura pattern for plugin startup. + */ + private T loadOrDefault(Path file, Class type) { + if (!Files.exists(file)) { + getLogger().info("Generating default " + file.getFileName() + " ..."); + StructuraWriters.saveDefault(file, type); + } + return Structura.load(file, type); + } + + // ------------------------------------------------------------------------- + // Command: /mctest + // ------------------------------------------------------------------------- + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 0) { + sender.sendMessage("Usage: /mctest "); + return true; + } + + switch (args[0].toLowerCase()) { + + case "reload" -> { + loadConfigs(); + sender.sendMessage("[McTest] Configs reloaded from disk."); + } + + case "save" -> { + // Demonstrate Structura.write() — persists the current in-memory config to disk + Path folder = getDataFolder().toPath(); + StructuraWriters.write(folder.resolve("config.yml"), mainConfig); + StructuraWriters.write(folder.resolve("database.yml"), databaseConfig); + StructuraWriters.write(folder.resolve("rewards.yml"), rewardsConfig); + sender.sendMessage("[McTest] Configs saved to disk."); + } + + case "info" -> { + sender.sendMessage("=== McTest Info ==="); + sender.sendMessage("Plugin name : " + mainConfig.pluginName()); + sender.sendMessage("Language : " + mainConfig.language()); + sender.sendMessage("Debug : " + mainConfig.debug()); + sender.sendMessage("Join radius : " + mainConfig.joinMessageRadius()); + sender.sendMessage("Discord webhook: " + (mainConfig.discordWebhook() != null + ? mainConfig.discordWebhook() : "(not set)")); + + sender.sendMessage("--- Database ---"); + DatabaseDriver driver = databaseConfig.driver(); + if (driver instanceof MySQLDriver mysql) { + sender.sendMessage("Driver : MySQL"); + sender.sendMessage("Host : " + mysql.host() + ":" + mysql.port()); + sender.sendMessage("DB name : " + mysql.database()); + sender.sendMessage("Pool : " + mysql.poolSize()); + } else if (driver instanceof SQLiteDriver sqlite) { + sender.sendMessage("Driver : SQLite"); + sender.sendMessage("File : " + sqlite.file()); + } + + sender.sendMessage("--- Daily rewards ---"); + printRewards(sender, rewardsConfig.daily()); + sender.sendMessage("--- Weekly rewards ---"); + printRewards(sender, rewardsConfig.weekly()); + } + + default -> sender.sendMessage("Unknown subcommand. Usage: /mctest "); + } + + return true; + } + + private void printRewards(CommandSender sender, List rewards) { + if (rewards == null || rewards.isEmpty()) { + sender.sendMessage(" (none)"); + return; + } + for (Reward reward : rewards) { + String desc = switch (reward) { + case ItemReward r -> "item " + r.amount() + "x " + r.material(); + case MoneyReward r -> "money " + r.amount(); + case CommandReward r -> "command " + r.command(); + default -> reward.toString(); + }; + sender.sendMessage(" - " + desc); + } + } +} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java new file mode 100644 index 0000000..6e1c1db --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java @@ -0,0 +1,50 @@ +package fr.traqueur.mctest.config; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.Polymorphic; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * Database configuration — database.yml + * + * The driver is polymorphic with inline=true, meaning the "type" discriminator + * appears at the root of the file rather than nested inside a "driver" block. + * + * MySQL example (database.yml): + * type: mysql + * driver: + * host: localhost + * port: 3306 + * database: mctest + * username: root + * password: secret + * pool-size: 10 + * + * SQLite example (database.yml): + * type: sqlite + * driver: + * file: plugins/McTest/data.db + */ +public record DatabaseConfig( + DatabaseDriver driver +) implements Loadable { + + @Polymorphic(key = "type", inline = true) + public interface DatabaseDriver extends Loadable {} + + public record MySQLDriver( + @DefaultString("localhost") String host, + @DefaultInt(3306) int port, + @DefaultString("mctest") String database, + @DefaultString("root") String username, + @DefaultString("") String password, + @DefaultInt(10) int poolSize + ) implements DatabaseDriver {} + + public record SQLiteDriver( + @DefaultString("plugins/McTest/data.db") String file + ) implements DatabaseDriver {} +} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java new file mode 100644 index 0000000..ad80239 --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java @@ -0,0 +1,26 @@ +package fr.traqueur.mctest.config; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * Main plugin configuration — config.yml + * + * Example YAML produced by Structura.saveDefault(): + * + * plugin-name: McTest + * language: en + * debug: false + * join-message-radius: 20 + * # discord-webhook is optional — omitted when null + */ +public record MainConfig( + @DefaultString("McTest") String pluginName, + @DefaultString("en") String language, + @DefaultBool(false) boolean debug, + @DefaultInt(20) int joinMessageRadius, + @Options(optional = true) String discordWebhook +) implements Loadable {} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java new file mode 100644 index 0000000..4623409 --- /dev/null +++ b/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java @@ -0,0 +1,54 @@ +package fr.traqueur.mctest.config; + +import fr.traqueur.structura.annotations.Polymorphic; +import fr.traqueur.structura.annotations.defaults.DefaultDouble; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +import java.util.List; + +/** + * Rewards configuration — rewards.yml + * + * Each reward is polymorphic: the "type" key inside each list entry + * determines which concrete record is instantiated. + * + * Example rewards.yml: + * + * daily: + * - type: item + * material: DIAMOND + * amount: 1 + * - type: money + * amount: 500.0 + * weekly: + * - type: item + * material: NETHERITE_INGOT + * amount: 1 + * - type: command + * command: give %player% golden_apple 5 + * message: Enjoy your apples! + */ +public record RewardsConfig( + List daily, + List weekly +) implements Loadable { + + @Polymorphic(key = "type") + public interface Reward extends Loadable {} + + public record ItemReward( + @DefaultString("DIAMOND") String material, + @DefaultInt(1) int amount + ) implements Reward {} + + public record MoneyReward( + @DefaultDouble(100.0) double amount + ) implements Reward {} + + public record CommandReward( + @DefaultString("say Hello %player%!") String command, + @DefaultString("") String message + ) implements Reward {} +} diff --git a/McTest/src/main/resources/plugin.yml b/McTest/src/main/resources/plugin.yml new file mode 100644 index 0000000..9dc4119 --- /dev/null +++ b/McTest/src/main/resources/plugin.yml @@ -0,0 +1,12 @@ +name: McTest +version: "${version}" +main: fr.traqueur.mctest.McTestPlugin +api-version: "1.21" +description: Example plugin demonstrating Structura config read/write +authors: [ Traqueur ] + +commands: + mctest: + description: McTest main command + usage: /mctest + permission: mctest.admin diff --git a/settings.gradle.kts b/settings.gradle.kts index fec4e94..a6d37a0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,4 +10,5 @@ pluginManagement { } } -include("structura-writers") \ No newline at end of file +include("structura-writers") +include("McTest") \ No newline at end of file From 941f1e078f1d10c8b786c3e1f3a97264f7955898 Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 15:46:01 +0200 Subject: [PATCH 10/12] fix: use explicit default instances for polymorphic configs in McTest saveDefault() cannot determine which concrete type to instantiate for polymorphic interface fields (DatabaseDriver, Reward). Split loadOrDefault into two helpers: loadOrSaveDefault for fully-annotated configs and loadOrWrite for configs that require an explicit fallback instance. --- .../java/fr/traqueur/mctest/McTestPlugin.java | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java index 516f396..d14ed52 100644 --- a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java +++ b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java @@ -60,16 +60,28 @@ private void setupRegistries() { private void loadConfigs() { Path folder = getDataFolder().toPath(); - mainConfig = loadOrDefault(folder.resolve("config.yml"), MainConfig.class); - databaseConfig = loadOrDefault(folder.resolve("database.yml"), DatabaseConfig.class); - rewardsConfig = loadOrDefault(folder.resolve("rewards.yml"), RewardsConfig.class); + // MainConfig: all fields have @Default* — saveDefault() works fine + mainConfig = loadOrSaveDefault(folder.resolve("config.yml"), MainConfig.class); + + // DatabaseConfig: driver field is a polymorphic interface — we must provide a concrete + // default instance explicitly (saveDefault cannot pick a concrete type on its own) + databaseConfig = loadOrWrite(folder.resolve("database.yml"), databaseConfig, + new DatabaseConfig(new DatabaseConfig.MySQLDriver("localhost", 3306, "mctest", "root", "", 10))); + + // RewardsConfig: list fields have no @Default* — same issue, provide explicit defaults + rewardsConfig = loadOrWrite(folder.resolve("rewards.yml"), rewardsConfig, + new RewardsConfig( + List.of(new RewardsConfig.ItemReward("DIAMOND", 1), + new RewardsConfig.MoneyReward(500.0)), + List.of(new RewardsConfig.ItemReward("NETHERITE_INGOT", 1), + new RewardsConfig.CommandReward("give %player% golden_apple 5", "Enjoy!")))); } /** - * Loads a config file if it exists, otherwise generates it from @Default* annotations. - * This is the standard Structura pattern for plugin startup. + * Loads a config whose every field has a {@code @Default*} annotation. + * Generates the file from annotations on first run. */ - private T loadOrDefault(Path file, Class type) { + private T loadOrSaveDefault(Path file, Class type) { if (!Files.exists(file)) { getLogger().info("Generating default " + file.getFileName() + " ..."); StructuraWriters.saveDefault(file, type); @@ -77,6 +89,19 @@ private T loadOrDefault(Path file return Structura.load(file, type); } + /** + * Loads a config that contains polymorphic or unannotated fields. + * Uses {@code fallback} as the written default when the file doesn't exist yet. + */ + @SuppressWarnings("unchecked") + private T loadOrWrite(Path file, T current, T fallback) { + if (!Files.exists(file)) { + getLogger().info("Generating default " + file.getFileName() + " ..."); + StructuraWriters.write(file, fallback); + } + return Structura.load(file, (Class) fallback.getClass()); + } + // ------------------------------------------------------------------------- // Command: /mctest // ------------------------------------------------------------------------- From 53bf33c768d1de37bd7ef48efaadce70ad20db7f Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 16:18:19 +0200 Subject: [PATCH 11/12] feat: add standalone Java example demonstrating Structura read/write cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-step console demo: saveDefault, load, modify+write, round-trip verify, polymorphic backend switch (Local → S3). No Minecraft dependency. --- example/build.gradle.kts | 33 +++++ .../main/java/fr/traqueur/example/Main.java | 135 ++++++++++++++++++ .../fr/traqueur/example/config/AppConfig.java | 25 ++++ .../example/config/StorageConfig.java | 49 +++++++ settings.gradle.kts | 3 +- 5 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 example/build.gradle.kts create mode 100644 example/src/main/java/fr/traqueur/example/Main.java create mode 100644 example/src/main/java/fr/traqueur/example/config/AppConfig.java create mode 100644 example/src/main/java/fr/traqueur/example/config/StorageConfig.java diff --git a/example/build.gradle.kts b/example/build.gradle.kts new file mode 100644 index 0000000..b1e920c --- /dev/null +++ b/example/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("java") + id("com.gradleup.shadow") version "9.0.0-beta11" +} + +group = "fr.traqueur" +version = rootProject.property("version") as String + +repositories { + mavenCentral() +} + +dependencies { + implementation(project(":")) + implementation(project(":structura-writers")) + implementation("org.yaml:snakeyaml:2.4") +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks.shadowJar { + archiveClassifier.set("") + manifest { + attributes["Main-Class"] = "fr.traqueur.example.Main" + } +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/example/src/main/java/fr/traqueur/example/Main.java b/example/src/main/java/fr/traqueur/example/Main.java new file mode 100644 index 0000000..859b55d --- /dev/null +++ b/example/src/main/java/fr/traqueur/example/Main.java @@ -0,0 +1,135 @@ +package fr.traqueur.example; + +import fr.traqueur.example.config.AppConfig; +import fr.traqueur.example.config.StorageConfig; +import fr.traqueur.example.config.StorageConfig.LocalBackend; +import fr.traqueur.example.config.StorageConfig.S3Backend; +import fr.traqueur.example.config.StorageConfig.StorageBackend; +import fr.traqueur.structura.api.Structura; +import fr.traqueur.structura.registries.PolymorphicRegistry; +import fr.traqueur.structura.writers.StructuraWriters; + +import java.nio.file.Files; +import java.nio.file.Path; + +public class Main { + + private static final Path CONFIG_DIR = Path.of("example-configs"); + + public static void main(String[] args) throws Exception { + + // ── 1. Setup polymorphic registries ─────────────────────────────────── + PolymorphicRegistry.create(StorageBackend.class, r -> { + r.register("local", LocalBackend.class); + r.register("s3", S3Backend.class); + }); + + System.out.println("╔══════════════════════════════════════╗"); + System.out.println("║ Structura — Java pure example ║"); + System.out.println("╚══════════════════════════════════════╝\n"); + + // ── 2. saveDefault — generate files from @Default* annotations ──────── + section("Step 1 — saveDefault()"); + + Path appFile = CONFIG_DIR.resolve("app.yml"); + Path storageFile = CONFIG_DIR.resolve("storage.yml"); + + if (!Files.exists(appFile)) { + StructuraWriters.saveDefault(appFile, AppConfig.class); + System.out.println("Generated: " + appFile); + } else { + System.out.println("Already exists: " + appFile); + } + + // StorageConfig has a polymorphic field — must use write() with an explicit default + if (!Files.exists(storageFile)) { + StorageConfig defaultStorage = new StorageConfig(new LocalBackend("./data", 100)); + StructuraWriters.write(storageFile, defaultStorage); + System.out.println("Generated: " + storageFile); + } else { + System.out.println("Already exists: " + storageFile); + } + + System.out.println("\nContents of app.yml:"); + System.out.println(Files.readString(appFile).indent(2).stripTrailing()); + + System.out.println("\nContents of storage.yml:"); + System.out.println(Files.readString(storageFile).indent(2).stripTrailing()); + + // ── 3. load — read config from disk ────────────────────────────────── + section("Step 2 — Structura.load()"); + + AppConfig appConfig = Structura.load(appFile, AppConfig.class); + StorageConfig storageConfig = Structura.load(storageFile, StorageConfig.class); + + System.out.println("app-name : " + appConfig.appName()); + System.out.println("version : " + appConfig.version()); + System.out.println("debug : " + appConfig.debug()); + System.out.println("max-connections : " + appConfig.maxConnections()); + System.out.println("admin-email : " + (appConfig.adminEmail() != null + ? appConfig.adminEmail() : "(not set — optional field)")); + + System.out.println(); + StorageBackend backend = storageConfig.backend(); + if (backend instanceof LocalBackend local) { + System.out.println("storage type : local"); + System.out.println("path : " + local.path()); + System.out.println("max-file-size : " + local.maxFileSizeMb() + " MB"); + } else if (backend instanceof S3Backend s3) { + System.out.println("storage type : s3"); + System.out.println("bucket : " + s3.bucket()); + System.out.println("region : " + s3.region()); + System.out.println("use-ssl : " + s3.useSsl()); + } + + // ── 4. modify in memory + write() ──────────────────────────────────── + section("Step 3 — modify + StructuraWriters.write()"); + + AppConfig updated = new AppConfig( + appConfig.appName(), + appConfig.version(), + true, // enable debug + appConfig.maxConnections(), + "admin@example.com" // set optional field + ); + StructuraWriters.write(appFile, updated); + System.out.println("Saved updated config (debug=true, admin-email set)."); + + System.out.println("\nNew contents of app.yml:"); + System.out.println(Files.readString(appFile).indent(2).stripTrailing()); + + // ── 5. reload from disk — verify round-trip ─────────────────────────── + section("Step 4 — reload and verify round-trip"); + + AppConfig reloaded = Structura.load(appFile, AppConfig.class); + System.out.println("debug after reload : " + reloaded.debug()); + System.out.println("admin-email : " + reloaded.adminEmail()); + System.out.println(); + + if (reloaded.debug() && "admin@example.com".equals(reloaded.adminEmail())) { + System.out.println("✓ Round-trip OK — write() then load() produces the same values."); + } else { + System.out.println("✗ Round-trip FAILED."); + } + + // ── 6. switch storage backend + write ──────────────────────────────── + section("Step 5 — switch storage backend to S3"); + + StorageConfig s3Config = new StorageConfig( + new S3Backend("prod-bucket", "us-east-1", "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI", true) + ); + StructuraWriters.write(storageFile, s3Config); + System.out.println("Switched to S3. New contents of storage.yml:"); + System.out.println(Files.readString(storageFile).indent(2).stripTrailing()); + + StorageConfig reloadedStorage = Structura.load(storageFile, StorageConfig.class); + System.out.println("Reloaded backend type : " + + (reloadedStorage.backend() instanceof S3Backend ? "S3 ✓" : "unexpected type ✗")); + + System.out.println("\nDone. Config files are in: " + CONFIG_DIR.toAbsolutePath()); + } + + private static void section(String title) { + System.out.println("\n── " + title + " " + "─".repeat(Math.max(0, 40 - title.length()))); + } +} diff --git a/example/src/main/java/fr/traqueur/example/config/AppConfig.java b/example/src/main/java/fr/traqueur/example/config/AppConfig.java new file mode 100644 index 0000000..b4fc3d8 --- /dev/null +++ b/example/src/main/java/fr/traqueur/example/config/AppConfig.java @@ -0,0 +1,25 @@ +package fr.traqueur.example.config; + +import fr.traqueur.structura.annotations.Options; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * General application config — app.yml + * + * Generated file: + * app-name: MyApp + * version: 1.0.0 + * debug: false + * max-connections: 50 + * # admin-email omitted (optional, no default) + */ +public record AppConfig( + @DefaultString("MyApp") String appName, + @DefaultString("1.0.0") String version, + @DefaultBool(false) boolean debug, + @DefaultInt(50) int maxConnections, + @Options(optional = true) String adminEmail +) implements Loadable {} diff --git a/example/src/main/java/fr/traqueur/example/config/StorageConfig.java b/example/src/main/java/fr/traqueur/example/config/StorageConfig.java new file mode 100644 index 0000000..31ef2ad --- /dev/null +++ b/example/src/main/java/fr/traqueur/example/config/StorageConfig.java @@ -0,0 +1,49 @@ +package fr.traqueur.example.config; + +import fr.traqueur.structura.annotations.Polymorphic; +import fr.traqueur.structura.annotations.defaults.DefaultBool; +import fr.traqueur.structura.annotations.defaults.DefaultInt; +import fr.traqueur.structura.annotations.defaults.DefaultString; +import fr.traqueur.structura.api.Loadable; + +/** + * Storage backend config — storage.yml + * + * The backend is polymorphic with inline=true: the "type" key appears at + * the root of the file, not nested inside a "backend" block. + * + * Local example: + * type: local + * backend: + * path: ./data + * max-file-size-mb: 100 + * + * S3 example: + * type: s3 + * backend: + * bucket: my-bucket + * region: eu-west-1 + * access-key: AKIAIOSFODNN7EXAMPLE + * secret-key: wJalrXUtnFEMI + * use-ssl: true + */ +public record StorageConfig( + StorageBackend backend +) implements Loadable { + + @Polymorphic(key = "type", inline = true) + public interface StorageBackend extends Loadable {} + + public record LocalBackend( + @DefaultString("./data") String path, + @DefaultInt(100) int maxFileSizeMb + ) implements StorageBackend {} + + public record S3Backend( + @DefaultString("my-bucket") String bucket, + @DefaultString("eu-west-1") String region, + @DefaultString("CHANGE_ME") String accessKey, + @DefaultString("CHANGE_ME") String secretKey, + @DefaultBool(true) boolean useSsl + ) implements StorageBackend {} +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a6d37a0..360ba7f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,4 +11,5 @@ pluginManagement { } include("structura-writers") -include("McTest") \ No newline at end of file +include("McTest") +include("example") \ No newline at end of file From d8b541375e2336ed3eeac158ea08f4d37e73216c Mon Sep 17 00:00:00 2001 From: el211 Date: Sat, 6 Jun 2026 16:24:48 +0200 Subject: [PATCH 12/12] chore: remove McTest plugin, keep standalone Java example only --- McTest/build.gradle.kts | 44 ----- .../java/fr/traqueur/mctest/McTestPlugin.java | 180 ------------------ .../mctest/config/DatabaseConfig.java | 50 ----- .../fr/traqueur/mctest/config/MainConfig.java | 26 --- .../traqueur/mctest/config/RewardsConfig.java | 54 ------ McTest/src/main/resources/plugin.yml | 12 -- settings.gradle.kts | 1 - 7 files changed, 367 deletions(-) delete mode 100644 McTest/build.gradle.kts delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java delete mode 100644 McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java delete mode 100644 McTest/src/main/resources/plugin.yml diff --git a/McTest/build.gradle.kts b/McTest/build.gradle.kts deleted file mode 100644 index 0c00dd5..0000000 --- a/McTest/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id("java") - id("com.gradleup.shadow") version "9.0.0-beta11" -} - -group = "fr.traqueur" -version = rootProject.property("version") as String - -repositories { - mavenCentral() - maven("https://repo.papermc.io/repository/maven-public/") -} - -dependencies { - // Paper API — provided at runtime by the server - compileOnly("io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT") - // SnakeYAML is bundled by Paper, so compileOnly here too - compileOnly("org.yaml:snakeyaml:2.4") - - // Structura core + writers — shaded into the jar - implementation(project(":")) - implementation(project(":structura-writers")) -} - -java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 -} - -tasks.processResources { - filesMatching("plugin.yml") { - expand("version" to project.version) - } -} - -tasks.shadowJar { - archiveClassifier.set("") - // Relocate Structura so it doesn't conflict if another plugin uses it - relocate("fr.traqueur.structura", "fr.traqueur.mctest.libs.structura") -} - -tasks.build { - dependsOn(tasks.shadowJar) -} diff --git a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java b/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java deleted file mode 100644 index d14ed52..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/McTestPlugin.java +++ /dev/null @@ -1,180 +0,0 @@ -package fr.traqueur.mctest; - -import fr.traqueur.mctest.config.DatabaseConfig; -import fr.traqueur.mctest.config.DatabaseConfig.DatabaseDriver; -import fr.traqueur.mctest.config.DatabaseConfig.MySQLDriver; -import fr.traqueur.mctest.config.DatabaseConfig.SQLiteDriver; -import fr.traqueur.mctest.config.MainConfig; -import fr.traqueur.mctest.config.RewardsConfig; -import fr.traqueur.mctest.config.RewardsConfig.CommandReward; -import fr.traqueur.mctest.config.RewardsConfig.ItemReward; -import fr.traqueur.mctest.config.RewardsConfig.MoneyReward; -import fr.traqueur.mctest.config.RewardsConfig.Reward; -import fr.traqueur.structura.api.Structura; -import fr.traqueur.structura.registries.PolymorphicRegistry; -import fr.traqueur.structura.writers.StructuraWriters; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.bukkit.plugin.java.JavaPlugin; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -public final class McTestPlugin extends JavaPlugin { - - private MainConfig mainConfig; - private DatabaseConfig databaseConfig; - private RewardsConfig rewardsConfig; - - @Override - public void onEnable() { - setupRegistries(); - loadConfigs(); - getLogger().info("McTest enabled — plugin name: " + mainConfig.pluginName()); - } - - @Override - public void onDisable() { - getLogger().info("McTest disabled."); - } - - // ------------------------------------------------------------------------- - // Config - // ------------------------------------------------------------------------- - - private void setupRegistries() { - // Register all polymorphic types before loading any config that uses them - PolymorphicRegistry.create(DatabaseDriver.class, r -> { - r.register("mysql", MySQLDriver.class); - r.register("sqlite", SQLiteDriver.class); - }); - - PolymorphicRegistry.create(Reward.class, r -> { - r.register("item", ItemReward.class); - r.register("money", MoneyReward.class); - r.register("command", CommandReward.class); - }); - } - - private void loadConfigs() { - Path folder = getDataFolder().toPath(); - - // MainConfig: all fields have @Default* — saveDefault() works fine - mainConfig = loadOrSaveDefault(folder.resolve("config.yml"), MainConfig.class); - - // DatabaseConfig: driver field is a polymorphic interface — we must provide a concrete - // default instance explicitly (saveDefault cannot pick a concrete type on its own) - databaseConfig = loadOrWrite(folder.resolve("database.yml"), databaseConfig, - new DatabaseConfig(new DatabaseConfig.MySQLDriver("localhost", 3306, "mctest", "root", "", 10))); - - // RewardsConfig: list fields have no @Default* — same issue, provide explicit defaults - rewardsConfig = loadOrWrite(folder.resolve("rewards.yml"), rewardsConfig, - new RewardsConfig( - List.of(new RewardsConfig.ItemReward("DIAMOND", 1), - new RewardsConfig.MoneyReward(500.0)), - List.of(new RewardsConfig.ItemReward("NETHERITE_INGOT", 1), - new RewardsConfig.CommandReward("give %player% golden_apple 5", "Enjoy!")))); - } - - /** - * Loads a config whose every field has a {@code @Default*} annotation. - * Generates the file from annotations on first run. - */ - private T loadOrSaveDefault(Path file, Class type) { - if (!Files.exists(file)) { - getLogger().info("Generating default " + file.getFileName() + " ..."); - StructuraWriters.saveDefault(file, type); - } - return Structura.load(file, type); - } - - /** - * Loads a config that contains polymorphic or unannotated fields. - * Uses {@code fallback} as the written default when the file doesn't exist yet. - */ - @SuppressWarnings("unchecked") - private T loadOrWrite(Path file, T current, T fallback) { - if (!Files.exists(file)) { - getLogger().info("Generating default " + file.getFileName() + " ..."); - StructuraWriters.write(file, fallback); - } - return Structura.load(file, (Class) fallback.getClass()); - } - - // ------------------------------------------------------------------------- - // Command: /mctest - // ------------------------------------------------------------------------- - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (args.length == 0) { - sender.sendMessage("Usage: /mctest "); - return true; - } - - switch (args[0].toLowerCase()) { - - case "reload" -> { - loadConfigs(); - sender.sendMessage("[McTest] Configs reloaded from disk."); - } - - case "save" -> { - // Demonstrate Structura.write() — persists the current in-memory config to disk - Path folder = getDataFolder().toPath(); - StructuraWriters.write(folder.resolve("config.yml"), mainConfig); - StructuraWriters.write(folder.resolve("database.yml"), databaseConfig); - StructuraWriters.write(folder.resolve("rewards.yml"), rewardsConfig); - sender.sendMessage("[McTest] Configs saved to disk."); - } - - case "info" -> { - sender.sendMessage("=== McTest Info ==="); - sender.sendMessage("Plugin name : " + mainConfig.pluginName()); - sender.sendMessage("Language : " + mainConfig.language()); - sender.sendMessage("Debug : " + mainConfig.debug()); - sender.sendMessage("Join radius : " + mainConfig.joinMessageRadius()); - sender.sendMessage("Discord webhook: " + (mainConfig.discordWebhook() != null - ? mainConfig.discordWebhook() : "(not set)")); - - sender.sendMessage("--- Database ---"); - DatabaseDriver driver = databaseConfig.driver(); - if (driver instanceof MySQLDriver mysql) { - sender.sendMessage("Driver : MySQL"); - sender.sendMessage("Host : " + mysql.host() + ":" + mysql.port()); - sender.sendMessage("DB name : " + mysql.database()); - sender.sendMessage("Pool : " + mysql.poolSize()); - } else if (driver instanceof SQLiteDriver sqlite) { - sender.sendMessage("Driver : SQLite"); - sender.sendMessage("File : " + sqlite.file()); - } - - sender.sendMessage("--- Daily rewards ---"); - printRewards(sender, rewardsConfig.daily()); - sender.sendMessage("--- Weekly rewards ---"); - printRewards(sender, rewardsConfig.weekly()); - } - - default -> sender.sendMessage("Unknown subcommand. Usage: /mctest "); - } - - return true; - } - - private void printRewards(CommandSender sender, List rewards) { - if (rewards == null || rewards.isEmpty()) { - sender.sendMessage(" (none)"); - return; - } - for (Reward reward : rewards) { - String desc = switch (reward) { - case ItemReward r -> "item " + r.amount() + "x " + r.material(); - case MoneyReward r -> "money " + r.amount(); - case CommandReward r -> "command " + r.command(); - default -> reward.toString(); - }; - sender.sendMessage(" - " + desc); - } - } -} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java deleted file mode 100644 index 6e1c1db..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/config/DatabaseConfig.java +++ /dev/null @@ -1,50 +0,0 @@ -package fr.traqueur.mctest.config; - -import fr.traqueur.structura.annotations.Options; -import fr.traqueur.structura.annotations.Polymorphic; -import fr.traqueur.structura.annotations.defaults.DefaultBool; -import fr.traqueur.structura.annotations.defaults.DefaultInt; -import fr.traqueur.structura.annotations.defaults.DefaultString; -import fr.traqueur.structura.api.Loadable; - -/** - * Database configuration — database.yml - * - * The driver is polymorphic with inline=true, meaning the "type" discriminator - * appears at the root of the file rather than nested inside a "driver" block. - * - * MySQL example (database.yml): - * type: mysql - * driver: - * host: localhost - * port: 3306 - * database: mctest - * username: root - * password: secret - * pool-size: 10 - * - * SQLite example (database.yml): - * type: sqlite - * driver: - * file: plugins/McTest/data.db - */ -public record DatabaseConfig( - DatabaseDriver driver -) implements Loadable { - - @Polymorphic(key = "type", inline = true) - public interface DatabaseDriver extends Loadable {} - - public record MySQLDriver( - @DefaultString("localhost") String host, - @DefaultInt(3306) int port, - @DefaultString("mctest") String database, - @DefaultString("root") String username, - @DefaultString("") String password, - @DefaultInt(10) int poolSize - ) implements DatabaseDriver {} - - public record SQLiteDriver( - @DefaultString("plugins/McTest/data.db") String file - ) implements DatabaseDriver {} -} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java deleted file mode 100644 index ad80239..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/config/MainConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.traqueur.mctest.config; - -import fr.traqueur.structura.annotations.Options; -import fr.traqueur.structura.annotations.defaults.DefaultBool; -import fr.traqueur.structura.annotations.defaults.DefaultInt; -import fr.traqueur.structura.annotations.defaults.DefaultString; -import fr.traqueur.structura.api.Loadable; - -/** - * Main plugin configuration — config.yml - * - * Example YAML produced by Structura.saveDefault(): - * - * plugin-name: McTest - * language: en - * debug: false - * join-message-radius: 20 - * # discord-webhook is optional — omitted when null - */ -public record MainConfig( - @DefaultString("McTest") String pluginName, - @DefaultString("en") String language, - @DefaultBool(false) boolean debug, - @DefaultInt(20) int joinMessageRadius, - @Options(optional = true) String discordWebhook -) implements Loadable {} diff --git a/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java b/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java deleted file mode 100644 index 4623409..0000000 --- a/McTest/src/main/java/fr/traqueur/mctest/config/RewardsConfig.java +++ /dev/null @@ -1,54 +0,0 @@ -package fr.traqueur.mctest.config; - -import fr.traqueur.structura.annotations.Polymorphic; -import fr.traqueur.structura.annotations.defaults.DefaultDouble; -import fr.traqueur.structura.annotations.defaults.DefaultInt; -import fr.traqueur.structura.annotations.defaults.DefaultString; -import fr.traqueur.structura.api.Loadable; - -import java.util.List; - -/** - * Rewards configuration — rewards.yml - * - * Each reward is polymorphic: the "type" key inside each list entry - * determines which concrete record is instantiated. - * - * Example rewards.yml: - * - * daily: - * - type: item - * material: DIAMOND - * amount: 1 - * - type: money - * amount: 500.0 - * weekly: - * - type: item - * material: NETHERITE_INGOT - * amount: 1 - * - type: command - * command: give %player% golden_apple 5 - * message: Enjoy your apples! - */ -public record RewardsConfig( - List daily, - List weekly -) implements Loadable { - - @Polymorphic(key = "type") - public interface Reward extends Loadable {} - - public record ItemReward( - @DefaultString("DIAMOND") String material, - @DefaultInt(1) int amount - ) implements Reward {} - - public record MoneyReward( - @DefaultDouble(100.0) double amount - ) implements Reward {} - - public record CommandReward( - @DefaultString("say Hello %player%!") String command, - @DefaultString("") String message - ) implements Reward {} -} diff --git a/McTest/src/main/resources/plugin.yml b/McTest/src/main/resources/plugin.yml deleted file mode 100644 index 9dc4119..0000000 --- a/McTest/src/main/resources/plugin.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: McTest -version: "${version}" -main: fr.traqueur.mctest.McTestPlugin -api-version: "1.21" -description: Example plugin demonstrating Structura config read/write -authors: [ Traqueur ] - -commands: - mctest: - description: McTest main command - usage: /mctest - permission: mctest.admin diff --git a/settings.gradle.kts b/settings.gradle.kts index 360ba7f..f8fc1fc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,5 +11,4 @@ pluginManagement { } include("structura-writers") -include("McTest") include("example") \ No newline at end of file