Skip to content

feat: structura-writers module — full symmetric serializer#24

Closed
el211 wants to merge 12 commits into
Traqueur-dev:mainfrom
el211:feat/structura-writers
Closed

feat: structura-writers module — full symmetric serializer#24
el211 wants to merge 12 commits into
Traqueur-dev:mainfrom
el211:feat/structura-writers

Conversation

@el211

@el211 el211 commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds the structura-writers module, a new submodule that provides symmetric YAML serialization for all features already supported by the Structura reader.

What's included

New module: structura-writers

  • LoadableSerializer — serializes any Loadable record to a YAML string
  • StructuraWriters — static entry point (write, saveDefault)
  • DefaultInstanceFactory — builds default instances from @Default* annotations
  • CustomWriterRegistry — allows registering custom serializers for arbitrary types
  • YamlStructuraWriter — SPI implementation wired via META-INF/services

New API on Structura (core)

  • Structura.write(Path, Loadable) — serialize and write to file
  • Structura.saveDefault(Path, Class<T>) — generate a default config file from annotations
  • Loaded lazily via ServiceLoader — no circular dependency, no mandatory dependency on structura-writers

Serialization features (symmetric with the reader)

  • camelCase → kebab-case key conversion
  • @Options(inline = true) — flatten sub-record fields
  • @Options(optional = true) — null fields silently omitted
  • @Options(name = "...") — custom YAML key
  • @Options(isKey = true) simple — { keyValue: { otherFields } }
  • @Options(isKey = true) complex — key sub-record fields flattened at same level
  • @Polymorphic standard — discriminator inside nested map
  • @Polymorphic(inline = true) — discriminator at parent level
  • @Polymorphic(inline = true) + @Options(inline = true) — fully inline
  • @Polymorphic(useKey = true) — discriminator value becomes the YAML key
  • LocalDate / LocalDateTime → ISO-8601
  • Enum → kebab-case
  • Reference<?> → key string
  • StructuraWriters.write() creates parent directories automatically

Test coverage — 34 tests across 3 suites

  • LoadableSerializerTest — unit tests for every serialization case
  • StructuraWritersTest — integration tests including polymorphic round-trips (write → Structura.load())
  • DefaultInstanceFactoryTest — default instance creation

Standalone Java example (example/ submodule)

  • Demonstrates the full read/write cycle: saveDefault, load, write, round-trip verify, polymorphic backend switch

Test plan

  • ./gradlew :structura-writers:test — all 34 tests pass
  • ./gradlew :example:shadowJar && java -jar example/build/libs/example-*.jar — demo runs end to end
  • Existing core tests still pass: ./gradlew test

el211 added 12 commits June 3, 2026 23:09
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<T> serializers
- Basic test coverage for the write/saveDefault/serializer/factory paths
- 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
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<T>: serialized as a YAML map keyed by discriminator value
- Map<String, T>: 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.
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.
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.
- 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.
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.
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<Reward> 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.
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.
…cycle

5-step console demo: saveDefault, load, modify+write, round-trip verify,
polymorphic backend switch (Local → S3). No Minecraft dependency.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants