diff --git a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java index 1a2ed95..cfb8b0c 100644 --- a/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java +++ b/aether-datafixers-schema-tools/src/main/java/de/splatgames/aether/datafixers/schematools/validation/SchemaValidator.java @@ -29,8 +29,14 @@ import de.splatgames.aether.datafixers.api.schema.SchemaRegistry; import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder; import de.splatgames.aether.datafixers.core.schema.SimpleSchemaRegistry; +import de.splatgames.aether.datafixers.schematools.analysis.CoverageGap; +import de.splatgames.aether.datafixers.schematools.analysis.FixCoverage; +import de.splatgames.aether.datafixers.schematools.analysis.MigrationAnalyzer; import org.jetbrains.annotations.NotNull; +import java.util.Comparator; +import java.util.List; + /** * Fluent API for validating schemas and schema registries. * @@ -365,20 +371,84 @@ private ValidationResult validateRegistry( /** * Validates that all type changes between schema versions have corresponding DataFixes. * - *

This method uses the {@link de.splatgames.aether.datafixers.schematools.analysis.MigrationAnalyzer} - * to analyze the migration path and identify any coverage gaps where types changed - * without a DataFix to handle the migration.

- * - *

Note: This is currently a placeholder implementation. - * Full integration with MigrationAnalyzer is pending.

+ *

This method uses the {@link MigrationAnalyzer} to analyze the migration path + * and identify any coverage gaps where types changed without a DataFix to handle + * the migration.

* * @return validation result containing coverage gap issues, never {@code null} */ @NotNull private ValidationResult validateFixCoverageInternal() { - // This will be fully implemented when MigrationAnalyzer is complete - // For now, return empty to allow the API to compile and work - // The actual coverage analysis requires MigrationAnalyzer which will be created next - return ValidationResult.empty(); + // Get version range from the registry + final List schemas = this.registry.stream().toList(); + if (schemas.size() < 2) { + // Not enough schemas to analyze coverage + return ValidationResult.empty(); + } + + // Determine version range + final int minVersion = schemas.stream() + .map(s -> s.version().getVersion()) + .min(Comparator.naturalOrder()) + .orElse(0); + final int maxVersion = schemas.stream() + .map(s -> s.version().getVersion()) + .max(Comparator.naturalOrder()) + .orElse(0); + + if (minVersion == maxVersion) { + return ValidationResult.empty(); + } + + // Use MigrationAnalyzer to analyze coverage + final MigrationAnalyzer analyzer = MigrationAnalyzer.forRegistries( + this.registry, + this.fixerBuilder.getFixRegistry() + ); + + final FixCoverage coverage = analyzer + .from(new DataVersion(minVersion)) + .to(new DataVersion(maxVersion)) + .includeFieldLevel(true) + .analyzeCoverage(); + + if (coverage.isFullyCovered()) { + return ValidationResult.empty(); + } + + // Convert coverage gaps to validation issues + final ValidationResult.Builder resultBuilder = ValidationResult.builder(); + + for (final CoverageGap gap : coverage.gaps()) { + final String location = String.format( + "v%d -> v%d", + gap.sourceVersion().getVersion(), + gap.targetVersion().getVersion() + ); + + final String message = gap.fieldName() + .map(field -> String.format( + "Missing DataFix for type '%s' field '%s': %s", + gap.type().getId(), + field, + gap.reason().description() + )) + .orElseGet(() -> String.format( + "Missing DataFix for type '%s': %s", + gap.type().getId(), + gap.reason().description() + )); + + resultBuilder.add( + ValidationIssue.warning("COVERAGE_MISSING_FIX", message) + .at(location) + .withContext("type", gap.type().getId()) + .withContext("reason", gap.reason().name()) + .withContext("sourceVersion", gap.sourceVersion().getVersion()) + .withContext("targetVersion", gap.targetVersion().getVersion()) + ); + } + + return resultBuilder.build(); } } diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java index 088f7bb..843a87e 100644 --- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java +++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DefaultMigrationService.java @@ -297,13 +297,9 @@ private class DefaultMigrationRequestBuilder implements MigrationRequestBuilder private String domain = DataFixerRegistry.DEFAULT_DOMAIN; /** - * Optional custom DynamicOps implementation. - * Reserved for future use when custom ops support is implemented. + * Optional custom DynamicOps implementation for format conversion. + * When set, the input data will be converted to this format before migration. */ - @SuppressFBWarnings( - value = "URF_UNREAD_FIELD", - justification = "Field is part of the public API (withOps method); implementation pending." - ) @Nullable private DynamicOps ops; @@ -395,6 +391,7 @@ public MigrationRequestBuilder withOps(@NotNull final DynamicOps ops) { *
  • Validates the builder configuration
  • *
  • Resolves the DataFixer from the registry
  • *
  • Resolves the target version if "toLatest" was specified
  • + *
  • Converts the input data to the specified DynamicOps format (if configured)
  • *
  • Executes the migration with timing
  • *
  • Records metrics if available
  • *
  • Returns a success or failure result
  • @@ -422,7 +419,10 @@ public MigrationResult execute() { final Instant start = Instant.now(); try { - final TaggedDynamic result = fixer.update(this.data, from, to); + // Convert to target format if custom ops are specified + final TaggedDynamic inputData = convertToTargetOps(this.data); + + final TaggedDynamic result = fixer.update(inputData, from, to); final Duration duration = Duration.between(start, Instant.now()); LOG.debug("Migration completed successfully in {}ms", duration.toMillis()); @@ -451,6 +451,31 @@ public MigrationResult execute() { } } + /** + * Converts the input data to the target DynamicOps format if custom ops are specified. + * + *

    If no custom ops are configured, the original data is returned unchanged. + * Otherwise, the data's Dynamic value is converted to the new format using + * {@link de.splatgames.aether.datafixers.api.dynamic.Dynamic#convert(DynamicOps)}.

    + * + * @param input the input TaggedDynamic to potentially convert + * @return the converted TaggedDynamic, or the original if no conversion is needed + */ + @NotNull + private TaggedDynamic convertToTargetOps(@NotNull final TaggedDynamic input) { + if (this.ops == null) { + return input; + } + + @SuppressWarnings("unchecked") + final DynamicOps targetOps = (DynamicOps) this.ops; + + final de.splatgames.aether.datafixers.api.dynamic.Dynamic converted = + input.value().convert(targetOps); + + return new TaggedDynamic(input.type(), converted); + } + /** * {@inheritDoc} *