Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -365,20 +371,84 @@ private ValidationResult validateRegistry(
/**
* Validates that all type changes between schema versions have corresponding DataFixes.
*
* <p>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.</p>
*
* <p><strong>Note:</strong> This is currently a placeholder implementation.
* Full integration with MigrationAnalyzer is pending.</p>
* <p>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.</p>
*
* @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<Schema> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -395,6 +391,7 @@ public <T> MigrationRequestBuilder withOps(@NotNull final DynamicOps<T> ops) {
* <li>Validates the builder configuration</li>
* <li>Resolves the DataFixer from the registry</li>
* <li>Resolves the target version if "toLatest" was specified</li>
* <li>Converts the input data to the specified DynamicOps format (if configured)</li>
* <li>Executes the migration with timing</li>
* <li>Records metrics if available</li>
* <li>Returns a success or failure result</li>
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -451,6 +451,31 @@ public MigrationResult execute() {
}
}

/**
* Converts the input data to the target DynamicOps format if custom ops are specified.
*
* <p>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)}.</p>
*
* @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<Object> targetOps = (DynamicOps<Object>) this.ops;

final de.splatgames.aether.datafixers.api.dynamic.Dynamic<?> converted =
input.value().convert(targetOps);

return new TaggedDynamic(input.type(), converted);
}

/**
* {@inheritDoc}
*
Expand Down
Loading