From 6b21ce68ebbfe8df8b2713b40cdf7b017925f128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?= Date: Mon, 6 Apr 2026 22:59:31 +0200 Subject: [PATCH 01/11] Introduce field-level diagnostics for rewrite rules Enable field-aware metadata aggregation and diagnostics for rewrite rules, enhancing visibility of affected fields in composed and standalone operations. Refactor combinators and wrappers to support this functionality. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Erik Pförtner --- .../api/diagnostic/DiagnosticOptions.java | 35 +- .../api/diagnostic/FieldOperation.java | 406 +++++++++++++++ .../api/diagnostic/FieldOperationType.java | 207 ++++++++ .../api/diagnostic/FixExecution.java | 25 + .../api/diagnostic/MigrationReport.java | 12 + .../api/diagnostic/RuleApplication.java | 71 ++- .../api/diagnostic/package-info.java | 7 + .../api/rewrite/BatchTransform.java | 77 ++- .../api/rewrite/FieldAwareRule.java | 97 ++++ .../aether/datafixers/api/rewrite/Rules.java | 282 ++++++++-- .../datafixers/api/rewrite/package-info.java | 2 + .../api/rewrite/FieldAwareRulesTest.java | 312 ++++++++++++ .../diagnostic/DiagnosticRuleWrapper.java | 109 +++- .../diagnostic/DiagnosticRecordsTest.java | 13 +- .../diagnostic/DiagnosticRuleWrapperTest.java | 143 ++++++ .../core/diagnostic/FieldOperationTest.java | 480 ++++++++++++++++++ .../diagnostic/FieldOperationTypeTest.java | 130 +++++ 17 files changed, 2327 insertions(+), 81 deletions(-) create mode 100644 aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java create mode 100644 aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java create mode 100644 aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRule.java create mode 100644 aether-datafixers-api/src/test/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRulesTest.java create mode 100644 aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTest.java create mode 100644 aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTypeTest.java diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java index 444fa89..f7a2d1b 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/DiagnosticOptions.java @@ -53,6 +53,10 @@ * @param captureRuleDetails whether to capture individual rule application details * @param maxSnapshotLength maximum length for snapshot strings (0 for unlimited) * @param prettyPrintSnapshots whether to format snapshots for readability + * @param captureFieldDetails whether to capture field-level operation metadata from + * {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule} + * implementations; requires {@code captureRuleDetails} to be + * {@code true} to have any effect (since 1.0.0) * @author Erik Pförtner * @see DiagnosticContext * @see MigrationReport @@ -62,7 +66,8 @@ public record DiagnosticOptions( boolean captureSnapshots, boolean captureRuleDetails, int maxSnapshotLength, - boolean prettyPrintSnapshots + boolean prettyPrintSnapshots, + boolean captureFieldDetails ) { /** @@ -79,13 +84,14 @@ public record DiagnosticOptions( *
  • {@code captureRuleDetails} = {@code true}
  • *
  • {@code maxSnapshotLength} = {@code 10000}
  • *
  • {@code prettyPrintSnapshots} = {@code true}
  • + *
  • {@code captureFieldDetails} = {@code true}
  • * * * @return default diagnostic options */ @NotNull public static DiagnosticOptions defaults() { - return new DiagnosticOptions(true, true, DEFAULT_MAX_SNAPSHOT_LENGTH, true); + return new DiagnosticOptions(true, true, DEFAULT_MAX_SNAPSHOT_LENGTH, true, true); } /** @@ -97,13 +103,14 @@ public static DiagnosticOptions defaults() { *
  • {@code captureRuleDetails} = {@code false}
  • *
  • {@code maxSnapshotLength} = {@code 0}
  • *
  • {@code prettyPrintSnapshots} = {@code false}
  • + *
  • {@code captureFieldDetails} = {@code false}
  • * * * @return minimal diagnostic options */ @NotNull public static DiagnosticOptions minimal() { - return new DiagnosticOptions(false, false, 0, false); + return new DiagnosticOptions(false, false, 0, false, false); } /** @@ -127,6 +134,7 @@ public static final class Builder { private boolean captureRuleDetails = true; private int maxSnapshotLength = DEFAULT_MAX_SNAPSHOT_LENGTH; private boolean prettyPrintSnapshots = true; + private boolean captureFieldDetails = true; private Builder() { } @@ -197,6 +205,24 @@ public Builder prettyPrintSnapshots(final boolean prettyPrintSnapshots) { return this; } + /** + * Sets whether to capture field-level operation metadata. + * + *

    When enabled and {@code captureRuleDetails} is also enabled, the migration + * report will include structured metadata about which fields each rule affects. + * This information is extracted from rules that implement + * {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule}.

    + * + * @param captureFieldDetails {@code true} to capture field-level details + * @return this builder + * @since 1.0.0 + */ + @NotNull + public Builder captureFieldDetails(final boolean captureFieldDetails) { + this.captureFieldDetails = captureFieldDetails; + return this; + } + /** * Builds the diagnostic options. * @@ -208,7 +234,8 @@ public DiagnosticOptions build() { this.captureSnapshots, this.captureRuleDetails, this.maxSnapshotLength, - this.prettyPrintSnapshots + this.prettyPrintSnapshots, + this.captureFieldDetails ); } } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java new file mode 100644 index 0000000..267cba0 --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperation.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.diagnostic; + +import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule; +import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Metadata about a single field-level operation within a + * {@link TypeRewriteRule} application. + * + *

    {@code FieldOperation} captures structured information about which fields + * are affected by a rule and how. This enables field-level diagnostic reporting beyond the type-level granularity of + * {@link RuleApplication}.

    + * + *

    Usage Example

    + *
    {@code
    + * MigrationReport report = context.getReport();
    + * for (FixExecution fix : report.fixExecutions()) {
    + *     for (RuleApplication rule : fix.ruleApplications()) {
    + *         for (FieldOperation fieldOp : rule.fieldOperations()) {
    + *             System.out.println(fieldOp.operationType() + " on " +
    + *                 String.join(".", fieldOp.fieldPath()));
    + *         }
    + *     }
    + * }
    + * }
    + * + *

    Path Representation

    + *

    Field paths are represented as a list of path segments. A top-level field + * {@code "name"} is represented as {@code ["name"]}, while a nested field {@code "position.x"} is represented as + * {@code ["position", "x"]}.

    + * + * @param operationType the kind of field operation, must not be {@code null} + * @param fieldPath the path segments to the affected field, must not be {@code null} or empty + * @param targetFieldName the target field name for operations that have one (e.g., rename target, move/copy destination + * as dot-notation path); {@code null} for operations without a target + * @param description optional human-readable description providing additional context; may be {@code null} + * @author Erik Pförtner + * @see FieldOperationType + * @see RuleApplication#fieldOperations() + * @see FieldAwareRule + * @since 1.0.0 + */ +public record FieldOperation( + @NotNull FieldOperationType operationType, + @NotNull List fieldPath, + @Nullable String targetFieldName, + @Nullable String description +) { + + /** + * Creates a new field operation record. + * + * @param operationType the kind of field operation, must not be {@code null} + * @param fieldPath the path segments to the affected field, must not be {@code null} or empty + * @param targetFieldName the target field name (may be {@code null}) + * @param description optional description (may be {@code null}) + * @throws NullPointerException if {@code operationType} or {@code fieldPath} is {@code null} + * @throws IllegalArgumentException if {@code fieldPath} is empty + */ + public FieldOperation { + Preconditions.checkNotNull(operationType, "operationType must not be null"); + Preconditions.checkNotNull(fieldPath, "fieldPath must not be null"); + Preconditions.checkArgument(!fieldPath.isEmpty(), "fieldPath must not be empty"); + fieldPath = List.copyOf(fieldPath); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Top-Level Fields + // ------------------------------------------------------------------------- + + /** + * Creates a rename field operation. + * + * @param oldName the current field name, must not be {@code null} + * @param newName the new field name, must not be {@code null} + * @return a new rename field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation rename(@NotNull final String oldName, + @NotNull final String newName) { + Preconditions.checkNotNull(oldName, "oldName must not be null"); + Preconditions.checkNotNull(newName, "newName must not be null"); + return new FieldOperation(FieldOperationType.RENAME, List.of(oldName), newName, null); + } + + /** + * Creates a remove field operation. + * + * @param fieldName the field name to remove, must not be {@code null} + * @return a new remove field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation remove(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.REMOVE, List.of(fieldName), null, null); + } + + /** + * Creates an add field operation. + * + * @param fieldName the field name to add, must not be {@code null} + * @return a new add field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation add(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.ADD, List.of(fieldName), null, null); + } + + /** + * Creates a transform field operation. + * + * @param fieldName the field name to transform, must not be {@code null} + * @return a new transform field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation transform(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.TRANSFORM, List.of(fieldName), null, null); + } + + /** + * Creates a set field operation. + * + * @param fieldName the field name to set, must not be {@code null} + * @return a new set field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation set(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.SET, List.of(fieldName), null, null); + } + + /** + * Creates a move field operation. + * + * @param sourcePath the source field path in dot-notation, must not be {@code null} + * @param targetPath the target field path in dot-notation, must not be {@code null} + * @return a new move field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation move(@NotNull final String sourcePath, + @NotNull final String targetPath) { + Preconditions.checkNotNull(sourcePath, "sourcePath must not be null"); + Preconditions.checkNotNull(targetPath, "targetPath must not be null"); + return new FieldOperation(FieldOperationType.MOVE, parsePath(sourcePath), targetPath, null); + } + + /** + * Creates a copy field operation. + * + * @param sourcePath the source field path in dot-notation, must not be {@code null} + * @param targetPath the target field path in dot-notation, must not be {@code null} + * @return a new copy field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation copy(@NotNull final String sourcePath, + @NotNull final String targetPath) { + Preconditions.checkNotNull(sourcePath, "sourcePath must not be null"); + Preconditions.checkNotNull(targetPath, "targetPath must not be null"); + return new FieldOperation(FieldOperationType.COPY, parsePath(sourcePath), targetPath, null); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Nested / Path-Based Fields + // ------------------------------------------------------------------------- + + /** + * Creates a rename operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field (e.g., {@code "position.posX"}), must not be {@code null} + * @param newName the new name for the leaf field, must not be {@code null} + * @return a new rename field operation with a nested path + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation renamePath(@NotNull final String path, + @NotNull final String newName) { + Preconditions.checkNotNull(path, "path must not be null"); + Preconditions.checkNotNull(newName, "newName must not be null"); + return new FieldOperation(FieldOperationType.RENAME, parsePath(path), newName, null); + } + + /** + * Creates a remove operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field, must not be {@code null} + * @return a new remove field operation with a nested path + * @throws NullPointerException if {@code path} is {@code null} + */ + @NotNull + public static FieldOperation removePath(@NotNull final String path) { + Preconditions.checkNotNull(path, "path must not be null"); + return new FieldOperation(FieldOperationType.REMOVE, parsePath(path), null, null); + } + + /** + * Creates an add operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field, must not be {@code null} + * @return a new add field operation with a nested path + * @throws NullPointerException if {@code path} is {@code null} + */ + @NotNull + public static FieldOperation addPath(@NotNull final String path) { + Preconditions.checkNotNull(path, "path must not be null"); + return new FieldOperation(FieldOperationType.ADD, parsePath(path), null, null); + } + + /** + * Creates a transform operation for a nested field specified by dot-notation path. + * + * @param path the dot-notation path to the field, must not be {@code null} + * @return a new transform field operation with a nested path + * @throws NullPointerException if {@code path} is {@code null} + */ + @NotNull + public static FieldOperation transformPath(@NotNull final String path) { + Preconditions.checkNotNull(path, "path must not be null"); + return new FieldOperation(FieldOperationType.TRANSFORM, parsePath(path), null, null); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Structural Operations + // ------------------------------------------------------------------------- + + /** + * Creates a group fields operation. + * + *

    This represents grouping multiple source fields into a nested object + * at the target field name.

    + * + * @param targetField the name of the new nested object field, must not be {@code null} + * @param sourceFields the fields to group into the target, must not be {@code null} or empty + * @return a new group field operation + * @throws NullPointerException if any argument is {@code null} + * @throws IllegalArgumentException if {@code sourceFields} is empty + */ + @NotNull + public static FieldOperation group(@NotNull final String targetField, + @NotNull final String... sourceFields) { + Preconditions.checkNotNull(targetField, "targetField must not be null"); + Preconditions.checkNotNull(sourceFields, "sourceFields must not be null"); + Preconditions.checkArgument(sourceFields.length > 0, "sourceFields must not be empty"); + return new FieldOperation( + FieldOperationType.GROUP, + List.copyOf(Arrays.asList(sourceFields)), + targetField, + null + ); + } + + /** + * Creates a flatten field operation. + * + * @param fieldName the name of the nested object to flatten, must not be {@code null} + * @return a new flatten field operation + * @throws NullPointerException if {@code fieldName} is {@code null} + */ + @NotNull + public static FieldOperation flatten(@NotNull final String fieldName) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + return new FieldOperation(FieldOperationType.FLATTEN, List.of(fieldName), null, null); + } + + // ------------------------------------------------------------------------- + // Factory Methods — Conditional Operations + // ------------------------------------------------------------------------- + + /** + * Creates a conditional field operation. + * + * @param fieldName the field name that the condition checks, must not be {@code null} + * @param conditionType a description of the condition (e.g., {@code "exists"}, {@code "missing"}, + * {@code "equals"}), must not be {@code null} + * @return a new conditional field operation + * @throws NullPointerException if any argument is {@code null} + */ + @NotNull + public static FieldOperation conditional(@NotNull final String fieldName, + @NotNull final String conditionType) { + Preconditions.checkNotNull(fieldName, "fieldName must not be null"); + Preconditions.checkNotNull(conditionType, "conditionType must not be null"); + return new FieldOperation( + FieldOperationType.CONDITIONAL, + List.of(fieldName), + null, + conditionType + ); + } + + // ------------------------------------------------------------------------- + // Convenience Methods + // ------------------------------------------------------------------------- + + /** + * Parses a dot-notation path string into a list of path segments. + * + * @param path the dot-notation path (e.g., {@code "position.x"}) + * @return list of path segments (e.g., {@code ["position", "x"]}) + */ + @NotNull + private static List parsePath(@NotNull final String path) { + return List.of(path.split("\\.")); + } + + /** + * Returns the target field name as an {@link Optional}. + * + * @return optional containing the target field name, or empty if none + */ + @NotNull + public Optional targetFieldNameOpt() { + return Optional.ofNullable(this.targetFieldName); + } + + /** + * Returns the description as an {@link Optional}. + * + * @return optional containing the description, or empty if none + */ + @NotNull + public Optional descriptionOpt() { + return Optional.ofNullable(this.description); + } + + /** + * Returns the field path as a dot-notation string. + * + *

    For example, a path of {@code ["position", "x"]} returns {@code "position.x"}.

    + * + * @return the dot-notation field path, never {@code null} + */ + @NotNull + public String fieldPathString() { + return String.join(".", this.fieldPath); + } + + /** + * Returns whether this operation targets a nested field (path depth > 1). + * + * @return {@code true} if the field path has more than one segment + */ + public boolean isNested() { + return this.fieldPath.size() > 1; + } + + // ------------------------------------------------------------------------- + // Internal Helpers + // ------------------------------------------------------------------------- + + /** + * Returns a human-readable summary of this field operation. + * + * @return formatted summary string, never {@code null} + */ + @NotNull + public String toSummary() { + final StringBuilder sb = new StringBuilder(); + sb.append(this.operationType.name()).append('(').append(fieldPathString()); + if (this.targetFieldName != null) { + sb.append(" -> ").append(this.targetFieldName); + } + sb.append(')'); + if (this.description != null) { + sb.append(" [").append(this.description).append(']'); + } + return sb.toString(); + } +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java new file mode 100644 index 0000000..ecd7f5a --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FieldOperationType.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.diagnostic; + +import de.splatgames.aether.datafixers.api.rewrite.Rules; +import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; +import org.jetbrains.annotations.NotNull; + +/** + * Classifies the kind of field-level operation performed by a + * {@link TypeRewriteRule} during a migration. + * + *

    Each constant corresponds to one or more factory methods in + * {@link Rules}:

    + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    Mapping of field operation types to Rules methods
    TypeRules method(s)
    {@link #RENAME}{@code renameField}, {@code renameFields}, {@code renameFieldAt}
    {@link #REMOVE}{@code removeField}, {@code removeFields}, {@code removeFieldAt}
    {@link #ADD}{@code addField}, {@code addFieldAt}
    {@link #TRANSFORM}{@code transformField}, {@code transformFieldAt}
    {@link #SET}{@code setField}
    {@link #MOVE}{@code moveField}
    {@link #COPY}{@code copyField}
    {@link #GROUP}{@code groupFields}
    {@link #FLATTEN}{@code flattenField}
    {@link #CONDITIONAL}{@code ifFieldExists}, {@code ifFieldMissing}, {@code ifFieldEquals}
    + * + * @author Erik Pförtner + * @see FieldOperation + * @see Rules + * @since 1.0.0 + */ +public enum FieldOperationType { + + /** + * A field is renamed to a new name. + * + * @see Rules#renameField + */ + RENAME("rename", true), + + /** + * A field is removed from the data structure. + * + * @see Rules#removeField + */ + REMOVE("remove", false), + + /** + * A new field is added with a default value (only if not already present). + * + * @see Rules#addField + */ + ADD("add", false), + + /** + * A field's value is transformed by a function. + * + * @see Rules#transformField + */ + TRANSFORM("transform", false), + + /** + * A field is set to a value, overwriting any existing value. + * + * @see Rules#setField + */ + SET("set", false), + + /** + * A field is moved from one location to another. + * + * @see Rules#moveField + */ + MOVE("move", true), + + /** + * A field's value is copied to another location (original preserved). + * + * @see Rules#copyField + */ + COPY("copy", true), + + /** + * Multiple fields are grouped into a nested object. + * + * @see Rules#groupFields + */ + GROUP("group", true), + + /** + * A nested object's fields are flattened into the parent. + * + * @see Rules#flattenField + */ + FLATTEN("flatten", false), + + /** + * A conditional operation that executes a rule based on field presence, absence, or value. + * + * @see Rules#ifFieldExists + * @see Rules#ifFieldMissing + * @see Rules#ifFieldEquals + */ + CONDITIONAL("conditional", false); + + /** + * Human-readable lowercase display name for this operation type. + */ + private final String displayName; + + /** + * Whether this operation type requires a target field name (e.g., rename target, move/copy destination). + */ + private final boolean requiresTarget; + + /** + * Creates a new field operation type constant. + * + * @param displayName the human-readable lowercase display name + * @param requiresTarget whether this operation requires a target field name + */ + FieldOperationType(@NotNull final String displayName, final boolean requiresTarget) { + this.displayName = displayName; + this.requiresTarget = requiresTarget; + } + + /** + * Returns the human-readable lowercase display name for this operation type. + * + *

    For example, {@link #RENAME} returns {@code "rename"} and + * {@link #CONDITIONAL} returns {@code "conditional"}.

    + * + * @return the display name, never {@code null} + */ + @NotNull + public String displayName() { + return this.displayName; + } + + /** + * Returns whether this operation type requires a target field name. + * + *

    Operations that produce a target include:

    + *
      + *
    • {@link #RENAME} — the new field name
    • + *
    • {@link #MOVE} — the destination path
    • + *
    • {@link #COPY} — the destination path
    • + *
    • {@link #GROUP} — the name of the new nested object
    • + *
    + * + * @return {@code true} if this operation type has a target field + */ + public boolean requiresTarget() { + return this.requiresTarget; + } + + /** + * Returns whether this operation type modifies the data structure layout rather than + * just individual field values. + * + *

    Structural operations change the shape of the data (nesting, flattening, moving fields + * between levels), while non-structural operations modify field values in place or add/remove + * individual fields at the same level.

    + * + *

    Structural types: {@link #MOVE}, {@link #COPY}, {@link #GROUP}, {@link #FLATTEN}.

    + * + * @return {@code true} if this is a structural operation + */ + public boolean isStructural() { + return this == MOVE || this == COPY || this == GROUP || this == FLATTEN; + } + + /** + * Returns the human-readable display name. + * + * @return the display name, never {@code null} + */ + @Override + @NotNull + public String toString() { + return this.displayName; + } +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java index 44f24cf..6e5df6f 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/FixExecution.java @@ -152,6 +152,31 @@ public int matchedRuleCount() { .count(); } + /** + * Returns all field operations across all rule applications in this fix. + * + * @return unmodifiable list of all field operations, never {@code null} + * @since 1.0.0 + */ + @NotNull + public List allFieldOperations() { + return this.ruleApplications.stream() + .flatMap(r -> r.fieldOperations().stream()) + .toList(); + } + + /** + * Returns the total number of field operations across all rule applications. + * + * @return total field operation count + * @since 1.0.0 + */ + public int fieldOperationCount() { + return this.ruleApplications.stream() + .mapToInt(r -> r.fieldOperations().size()) + .sum(); + } + /** * Returns a human-readable summary of this fix execution. * diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java index bca8c76..9a39562 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/MigrationReport.java @@ -157,6 +157,18 @@ default int ruleApplicationCount() { .sum(); } + /** + * Returns the total number of field-level operations across all fixes and rule applications. + * + * @return total field operation count + * @since 1.0.0 + */ + default int totalFieldOperationCount() { + return this.fixExecutions().stream() + .mapToInt(FixExecution::fieldOperationCount) + .sum(); + } + // ------------------------------------------------------------------------- // Touched Types // ------------------------------------------------------------------------- diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java index cce6961..3fd4d75 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/RuleApplication.java @@ -28,6 +28,7 @@ import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Optional; /** @@ -49,15 +50,19 @@ * } * } * - * @param ruleName the name of the rule (from {@code TypeRewriteRule.toString()}) - * @param typeName the name of the type being processed - * @param timestamp the instant when the rule was applied - * @param duration the time taken to apply the rule - * @param matched whether the rule matched and transformed the data - * @param description optional additional description or context + * @param ruleName the name of the rule (from {@code TypeRewriteRule.toString()}) + * @param typeName the name of the type being processed + * @param timestamp the instant when the rule was applied + * @param duration the time taken to apply the rule + * @param matched whether the rule matched and transformed the data + * @param description optional additional description or context + * @param fieldOperations structured metadata about field-level operations performed by this rule; + * empty if the rule does not carry field-level metadata or if field detail + * capture is disabled (since 1.0.0) * @author Erik Pförtner * @see FixExecution * @see MigrationReport + * @see FieldOperation * @since 0.2.0 */ public record RuleApplication( @@ -66,18 +71,20 @@ public record RuleApplication( @NotNull Instant timestamp, @NotNull Duration duration, boolean matched, - @Nullable String description + @Nullable String description, + @NotNull List fieldOperations ) { /** * Creates a new rule application record. * - * @param ruleName the name of the rule, must not be {@code null} - * @param typeName the name of the type being processed, must not be {@code null} - * @param timestamp the instant when the rule was applied, must not be {@code null} - * @param duration the time taken to apply the rule, must not be {@code null} - * @param matched whether the rule matched and transformed the data - * @param description optional additional description (may be {@code null}) + * @param ruleName the name of the rule, must not be {@code null} + * @param typeName the name of the type being processed, must not be {@code null} + * @param timestamp the instant when the rule was applied, must not be {@code null} + * @param duration the time taken to apply the rule, must not be {@code null} + * @param matched whether the rule matched and transformed the data + * @param description optional additional description (may be {@code null}) + * @param fieldOperations field-level operation metadata, must not be {@code null} * @throws NullPointerException if any required parameter is {@code null} */ public RuleApplication { @@ -85,10 +92,12 @@ public record RuleApplication( Preconditions.checkNotNull(typeName, "typeName must not be null"); Preconditions.checkNotNull(timestamp, "timestamp must not be null"); Preconditions.checkNotNull(duration, "duration must not be null"); + Preconditions.checkNotNull(fieldOperations, "fieldOperations must not be null"); + fieldOperations = List.copyOf(fieldOperations); } /** - * Creates a new rule application record without a description. + * Creates a new rule application record without a description or field operations. * * @param ruleName the name of the rule, must not be {@code null} * @param typeName the name of the type being processed, must not be {@code null} @@ -105,7 +114,7 @@ public static RuleApplication of( @NotNull final Duration duration, final boolean matched ) { - return new RuleApplication(ruleName, typeName, timestamp, duration, matched, null); + return new RuleApplication(ruleName, typeName, timestamp, duration, matched, null, List.of()); } /** @@ -127,6 +136,32 @@ public long durationMillis() { return this.duration.toMillis(); } + /** + * Returns whether this rule application carries field-level operation metadata. + * + * @return {@code true} if this application has at least one field operation + * @since 1.0.0 + */ + public boolean hasFieldOperations() { + return !this.fieldOperations.isEmpty(); + } + + /** + * Returns the field operations of a specific type. + * + * @param type the operation type to filter by, must not be {@code null} + * @return an unmodifiable list of matching field operations, never {@code null} + * @throws NullPointerException if {@code type} is {@code null} + * @since 1.0.0 + */ + @NotNull + public List fieldOperationsOfType(@NotNull final FieldOperationType type) { + Preconditions.checkNotNull(type, "type must not be null"); + return this.fieldOperations.stream() + .filter(op -> op.operationType() == type) + .toList(); + } + /** * Returns a human-readable summary of this rule application. * @@ -134,11 +169,15 @@ public long durationMillis() { */ @NotNull public String toSummary() { - return String.format("%s on %s: %s in %dms", + final String base = String.format("%s on %s: %s in %dms", this.ruleName, this.typeName, this.matched ? "matched" : "skipped", this.durationMillis() ); + if (this.fieldOperations.isEmpty()) { + return base; + } + return base + String.format(" (%d field ops)", this.fieldOperations.size()); } } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java index f0eb96d..c3ca3fa 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/diagnostic/package-info.java @@ -31,6 +31,7 @@ *
  • Timing information for the overall migration and individual fixes
  • *
  • Details about each applied {@link de.splatgames.aether.datafixers.api.fix.DataFix}
  • *
  • Individual {@link de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule} applications
  • + *
  • Field-level operation metadata from {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule} implementations
  • *
  • Before/after data snapshots for debugging
  • *
  • Warnings and diagnostic messages
  • * @@ -52,6 +53,12 @@ * *
    {@link de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions}
    *
    Configuration for controlling what data is captured
    + * + *
    {@link de.splatgames.aether.datafixers.api.diagnostic.FieldOperation}
    + *
    Structured metadata about a single field-level operation within a rule
    + * + *
    {@link de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType}
    + *
    Classifies the kind of field-level operation (rename, remove, add, etc.)
    * * *

    Usage Example

    diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java index 334cf1c..a8d0ef0 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/BatchTransform.java @@ -23,6 +23,8 @@ package de.splatgames.aether.datafixers.api.rewrite; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; import de.splatgames.aether.datafixers.api.dynamic.Dynamic; import de.splatgames.aether.datafixers.api.dynamic.DynamicOps; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -68,6 +70,12 @@ *
  • {@link #addIfMissing(String, Function)} - Add field only if missing
  • * * + *

    Diagnostics

    + *

    The {@link #diagnosticFieldOperations()} method provides structured metadata + * about all batch operations for the diagnostic system. When used via {@link Rules#batch}, + * this metadata is automatically attached to the resulting rule as + * {@link FieldOperation} records.

    + * *

    Thread Safety

    *

    This class is not thread-safe during construction. Once built into a * {@link TypeRewriteRule} via {@link Rules#batch}, the resulting rule is thread-safe.

    @@ -79,7 +87,7 @@ */ public final class BatchTransform { - private final List> operations = new ArrayList<>(); + private final List> operations = new ArrayList<>(); @SuppressFBWarnings( value = "EI_EXPOSE_REP2", justification = "DynamicOps is a stateless strategy object stored for future extensions of this builder; exposing/copying is neither required nor meaningful." @@ -240,7 +248,7 @@ public Dynamic apply(@NotNull final Dynamic input) { Preconditions.checkNotNull(input, "input must not be null"); Dynamic result = input; - for (final FieldOperation op : this.operations) { + for (final BatchOp op : this.operations) { result = op.apply(result); } return result; @@ -264,6 +272,59 @@ public boolean isEmpty() { return this.operations.isEmpty(); } + /** + * Returns diagnostic field operation metadata for all operations in this batch. + * + *

    This method converts the internal batch operations to + * {@link FieldOperation} records + * for use by the diagnostic system. Each batch operation is mapped to its + * corresponding diagnostic field operation type.

    + * + * @return an unmodifiable list of diagnostic field operations, never {@code null} + * @since 1.0.0 + */ + @NotNull + public List diagnosticFieldOperations() { + return this.operations.stream() + .map(BatchTransform::toDiagnosticFieldOp) + .toList(); + } + + /** + * Converts an internal batch operation to a diagnostic field operation record. + * + * @param op the internal operation to convert + * @param the underlying data format type + * @return the corresponding diagnostic field operation + * @since 1.0.0 + */ + @NotNull + private static FieldOperation toDiagnosticFieldOp( + @NotNull final BatchOp op) { + if (op instanceof RenameOp r) { + return FieldOperation.rename(r.from(), r.to()); + } + if (op instanceof RemoveOp r) { + return FieldOperation.remove(r.field()); + } + if (op instanceof SetOp r) { + return FieldOperation.set(r.field()); + } + if (op instanceof TransformOp r) { + return FieldOperation.transform(r.field()); + } + if (op instanceof AddIfMissingOp r) { + return FieldOperation.add(r.field()); + } + // Fallback for any future operation types + return new FieldOperation( + FieldOperationType.TRANSFORM, + List.of("unknown"), + null, + "unknown batch operation" + ); + } + // ==================== Internal Operation Classes ==================== /** @@ -276,7 +337,7 @@ public boolean isEmpty() { * @param the underlying data format type * @since 0.4.0 */ - private interface FieldOperation { + private interface BatchOp { /** * Applies this operation to the given dynamic value. @@ -299,7 +360,7 @@ private interface FieldOperation { * @param the underlying data format type * @since 0.4.0 */ - private record RenameOp(String from, String to) implements FieldOperation { + private record RenameOp(String from, String to) implements BatchOp { /** * {@inheritDoc} @@ -330,7 +391,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @param the underlying data format type * @since 0.4.0 */ - private record RemoveOp(String field) implements FieldOperation { + private record RemoveOp(String field) implements BatchOp { /** * {@inheritDoc} @@ -358,7 +419,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @param the underlying data format type * @since 0.4.0 */ - private record SetOp(String field, Function, Dynamic> valueSupplier) implements FieldOperation { + private record SetOp(String field, Function, Dynamic> valueSupplier) implements BatchOp { /** * {@inheritDoc} @@ -387,7 +448,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @since 0.4.0 */ private record TransformOp(String field, - Function, Dynamic> transform) implements FieldOperation { + Function, Dynamic> transform) implements BatchOp { /** * {@inheritDoc} @@ -421,7 +482,7 @@ public Dynamic apply(@NotNull final Dynamic dynamic) { * @since 0.4.0 */ private record AddIfMissingOp(String field, - Function, Dynamic> valueSupplier) implements FieldOperation { + Function, Dynamic> valueSupplier) implements BatchOp { /** * {@inheritDoc} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRule.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRule.java new file mode 100644 index 0000000..b4aea67 --- /dev/null +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRule.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.rewrite; + +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * A marker interface for {@link TypeRewriteRule} implementations that carry + * structured field-level metadata. + * + *

    {@code FieldAwareRule} is a separate interface rather than a default method + * on {@link TypeRewriteRule} because {@code TypeRewriteRule} is a + * {@link FunctionalInterface} and cannot have additional abstract methods. + * Rules created by the field operation methods in {@link Rules} (such as + * {@code renameField}, {@code removeField}, etc.) implement both + * {@code TypeRewriteRule} and {@code FieldAwareRule}.

    + * + *

    Usage in Diagnostics

    + *

    The diagnostic system uses {@code instanceof FieldAwareRule} to detect + * whether a rule carries field-level metadata. When present, the metadata is + * included in the {@link RuleApplication} + * record for that rule execution.

    + * + *

    Usage Example

    + *
    {@code
    + * TypeRewriteRule rule = Rules.renameField(ops, "oldName", "newName");
    + *
    + * if (rule instanceof FieldAwareRule fieldAware) {
    + *     List fieldOps = fieldAware.fieldOperations();
    + *     // fieldOps contains: [FieldOperation.rename("oldName", "newName")]
    + * }
    + * }
    + * + *

    Implementing Custom Field-Aware Rules

    + *

    Custom rules can implement this interface to participate in field-level + * diagnostics:

    + *
    {@code
    + * public class MyCustomRule implements TypeRewriteRule, FieldAwareRule {
    + *     @Override
    + *     public Optional> rewrite(Type type, Typed input) {
    + *         // rule logic
    + *     }
    + *
    + *     @Override
    + *     public List fieldOperations() {
    + *         return List.of(FieldOperation.transform("myField"));
    + *     }
    + * }
    + * }
    + * + * @author Erik Pförtner + * @see TypeRewriteRule + * @see FieldOperation + * @see Rules + * @since 1.0.0 + */ +public interface FieldAwareRule { + + /** + * Returns the list of field-level operations that this rule performs. + * + *

    The returned list describes which fields are affected and how. For + * simple rules (e.g., a single {@code renameField}), this typically contains + * one entry. For batch rules (e.g., {@code renameFields} with multiple + * mappings), this may contain multiple entries.

    + * + *

    The returned list must be immutable.

    + * + * @return an unmodifiable list of field operations, never {@code null} + */ + @NotNull + List fieldOperations(); +} diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java index 1c47e68..3e10f7b 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java @@ -23,6 +23,7 @@ package de.splatgames.aether.datafixers.api.rewrite; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; import de.splatgames.aether.datafixers.api.dynamic.Dynamic; import de.splatgames.aether.datafixers.api.dynamic.DynamicOps; import de.splatgames.aether.datafixers.api.optic.Finder; @@ -31,6 +32,7 @@ import de.splatgames.aether.datafixers.api.type.Typed; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -138,6 +140,11 @@ private Rules() { * Typed result = migration.apply(playerData); * } * + *

    Field-Level Diagnostics

    + *

    If any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata. This allows the diagnostic system to report which fields are + * affected even through compositions.

    + * * @param rules the rules to apply in sequence; if empty, returns identity rule * @return a composed rule requiring all rules to match, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -151,7 +158,8 @@ public static TypeRewriteRule seq(@NotNull final TypeRewriteRule... rules) { if (rules.length == 1) { return rules[0]; } - return new TypeRewriteRule() { + final String ruleName = "seq(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public Optional> rewrite(@NotNull final Type type, @@ -171,9 +179,14 @@ public Optional> rewrite(@NotNull final Type type, @Override public String toString() { - return "seq(" + Arrays.toString(rules) + ")"; + return ruleName; } }; + final List aggregated = collectFieldOperations(rules); + if (!aggregated.isEmpty()) { + return withFieldOps(base, aggregated, ruleName); + } + return base; } /** @@ -196,6 +209,10 @@ public String toString() { * Typed result = migration.apply(data); * } * + *

    Field-Level Diagnostics

    + *

    If any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata for diagnostic reporting.

    + * * @param rules the rules to try in sequence; non-matching rules are skipped * @return a composed rule that always succeeds, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -203,7 +220,8 @@ public String toString() { @NotNull public static TypeRewriteRule seqAll(@NotNull final TypeRewriteRule... rules) { Preconditions.checkNotNull(rules, "rules must not be null"); - return new TypeRewriteRule() { + final String ruleName = "seqAll(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public Optional> rewrite(@NotNull final Type type, @@ -219,9 +237,14 @@ public Optional> rewrite(@NotNull final Type type, @Override public String toString() { - return "seqAll(" + Arrays.toString(rules) + ")"; + return ruleName; } }; + final List aggregated = collectFieldOperations(rules); + if (!aggregated.isEmpty()) { + return withFieldOps(base, aggregated, ruleName); + } + return base; } /** @@ -244,6 +267,11 @@ public String toString() { * Typed result = versionFix.apply(data); * } * + *

    Field-Level Diagnostics

    + *

    If any child rules implement {@link FieldAwareRule}, the composed rule + * aggregates their {@link FieldOperation} metadata from all alternatives, since any one of them may match at + * runtime.

    + * * @param rules the rules to try in order; first match wins * @return a composed rule that uses the first matching rule, never {@code null} * @throws NullPointerException if {@code rules} or any element is {@code null} @@ -251,7 +279,8 @@ public String toString() { @NotNull public static TypeRewriteRule choice(@NotNull final TypeRewriteRule... rules) { Preconditions.checkNotNull(rules, "rules must not be null"); - return new TypeRewriteRule() { + final String ruleName = "choice(" + Arrays.toString(rules) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @NotNull @Override public Optional> rewrite(@NotNull final Type type, @@ -269,9 +298,14 @@ public Optional> rewrite(@NotNull final Type type, @Override public String toString() { - return "choice(" + Arrays.toString(rules) + ")"; + return ruleName; } }; + final List aggregated = collectFieldOperations(rules); + if (!aggregated.isEmpty()) { + return withFieldOps(base, aggregated, ruleName); + } + return base; } /** @@ -960,7 +994,8 @@ public static TypeRewriteRule renameField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(oldName, "oldName must not be null"); Preconditions.checkNotNull(newName, "newName must not be null"); - return new TypeRewriteRule() { + final String ruleName = "renameField(" + oldName + " -> " + newName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public @NotNull Optional> rewrite(@NotNull final Type type, @@ -983,9 +1018,10 @@ public static TypeRewriteRule renameField(@NotNull final DynamicOps ops, @Override public String toString() { - return "renameField(" + oldName + " -> " + newName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.rename(oldName, newName)), ruleName); } /** @@ -1017,7 +1053,8 @@ public static TypeRewriteRule removeField(@NotNull final DynamicOps ops, @NotNull final String fieldName) { Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); - return new TypeRewriteRule() { + final String ruleName = "removeField(" + fieldName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public @NotNull Optional> rewrite(@NotNull final Type type, @@ -1034,9 +1071,10 @@ public static TypeRewriteRule removeField(@NotNull final DynamicOps ops, @Override public String toString() { - return "removeField(" + fieldName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.remove(fieldName)), ruleName); } /** @@ -1077,7 +1115,8 @@ public static TypeRewriteRule addField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(defaultValue, "defaultValue must not be null"); - return new TypeRewriteRule() { + final String ruleName = "addField(" + fieldName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public @NotNull Optional> rewrite(@NotNull final Type type, @@ -1100,9 +1139,10 @@ public static TypeRewriteRule addField(@NotNull final DynamicOps ops, @Override public String toString() { - return "addField(" + fieldName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.add(fieldName)), ruleName); } /** @@ -1145,12 +1185,9 @@ public static TypeRewriteRule transformField(@NotNull final DynamicOps op Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return updateAt( - "transformField(" + fieldName + ")", - ops, - Finder.field(fieldName), - transform - ); + final String ruleName = "transformField(" + fieldName + ")"; + final TypeRewriteRule base = updateAt(ruleName, ops, Finder.field(fieldName), transform); + return withFieldOps(base, List.of(FieldOperation.transform(fieldName)), ruleName); } // ==================== Batch Operations ==================== @@ -1181,6 +1218,10 @@ public static TypeRewriteRule transformField(@NotNull final DynamicOps op * ); * } * + *

    Field-Level Diagnostics

    + *

    The returned rule implements {@link FieldAwareRule} with field operation + * metadata derived from the batch operations (rename, remove, set, transform, addIfMissing).

    + * * @param the underlying data format type (e.g., JsonElement) * @param ops the dynamic operations for the data format, must not be {@code null} * @param builder a consumer that configures the batch operations, must not be {@code null} @@ -1202,10 +1243,16 @@ public static TypeRewriteRule batch(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return dynamicTransform("batch[" + batch.size() + " ops]", ops, dynamic -> { + final String ruleName = "batch[" + batch.size() + " ops]"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; return batch.apply(typedDynamic); }); + final List fieldOps = batch.diagnosticFieldOperations(); + if (!fieldOps.isEmpty()) { + return withFieldOps(base, fieldOps, ruleName); + } + return base; } // ==================== Extended Dynamic Transformation Combinators ==================== @@ -1303,8 +1350,8 @@ public static TypeRewriteRule setField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(value, "value must not be null"); - - return new TypeRewriteRule() { + final String ruleName = "setField(" + fieldName + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public @NotNull Optional> rewrite(@NotNull final Type type, @@ -1321,9 +1368,10 @@ public static TypeRewriteRule setField(@NotNull final DynamicOps ops, @Override public String toString() { - return "setField(" + fieldName + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.set(fieldName)), ruleName); } /** @@ -1365,7 +1413,8 @@ public static TypeRewriteRule renameFields(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return new TypeRewriteRule() { + final String ruleName = "renameFields(" + renames + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public @NotNull Optional> rewrite(@NotNull final Type type, @@ -1390,9 +1439,13 @@ public static TypeRewriteRule renameFields(@NotNull final DynamicOps ops, @Override public String toString() { - return "renameFields(" + renames + ")"; + return ruleName; } }; + final List fieldOps = renames.entrySet().stream() + .map(e -> FieldOperation.rename(e.getKey(), e.getValue())) + .toList(); + return withFieldOps(base, fieldOps, ruleName); } /** @@ -1430,7 +1483,8 @@ public static TypeRewriteRule removeFields(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return new TypeRewriteRule() { + final String ruleName = "removeFields(" + Arrays.toString(fieldNames) + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public @NotNull Optional> rewrite(@NotNull final Type type, @@ -1450,9 +1504,13 @@ public static TypeRewriteRule removeFields(@NotNull final DynamicOps ops, @Override public String toString() { - return "removeFields(" + Arrays.toString(fieldNames) + ")"; + return ruleName; } }; + final List fieldOps = Arrays.stream(fieldNames) + .map(FieldOperation::remove) + .toList(); + return withFieldOps(base, fieldOps, ruleName); } // ==================== Grouping and Moving Combinators ==================== @@ -1496,7 +1554,8 @@ public static TypeRewriteRule groupFields(@NotNull final DynamicOps ops, return TypeRewriteRule.identity(); } - return dynamicTransform("groupFields(" + targetField + ")", ops, dynamic -> { + final String ruleName = "groupFields(" + targetField + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") Dynamic typedDynamic = (Dynamic) dynamic; @@ -1516,6 +1575,7 @@ public static TypeRewriteRule groupFields(@NotNull final DynamicOps ops, } return result.set(targetField, nested); }); + return withFieldOps(base, List.of(FieldOperation.group(targetField, sourceFields)), ruleName); } /** @@ -1549,7 +1609,8 @@ public static TypeRewriteRule flattenField(@NotNull final DynamicOps ops, Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(fieldName, "fieldName must not be null"); - return dynamicTransform("flattenField(" + fieldName + ")", ops, dynamic -> { + final String ruleName = "flattenField(" + fieldName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") Dynamic typedDynamic = (Dynamic) dynamic; @@ -1580,6 +1641,7 @@ public static TypeRewriteRule flattenField(@NotNull final DynamicOps ops, } return result; }); + return withFieldOps(base, List.of(FieldOperation.flatten(fieldName)), ruleName); } /** @@ -1620,7 +1682,8 @@ public static TypeRewriteRule moveField(@NotNull final DynamicOps ops, final Finder sourceFinder = parsePath(sourcePath); parsePath(targetPath); // Parse and cache target path eagerly to validate the syntax early. - return dynamicTransform("moveField(" + sourcePath + " -> " + targetPath + ")", ops, dynamic -> { + final String ruleName = "moveField(" + sourcePath + " -> " + targetPath + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { final Dynamic value = sourceFinder.get(dynamic); if (value == null) { return dynamic; // Source doesn't exist, nothing to move @@ -1630,6 +1693,7 @@ public static TypeRewriteRule moveField(@NotNull final DynamicOps ops, Dynamic result = removeAtPath(dynamic, sourcePath); return setAtPath(result, targetPath, value); }); + return withFieldOps(base, List.of(FieldOperation.move(sourcePath, targetPath)), ruleName); } /** @@ -1669,7 +1733,8 @@ public static TypeRewriteRule copyField(@NotNull final DynamicOps ops, final Finder sourceFinder = parsePath(sourcePath); - return dynamicTransform("copyField(" + sourcePath + " -> " + targetPath + ")", ops, dynamic -> { + final String ruleName = "copyField(" + sourcePath + " -> " + targetPath + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { final Dynamic value = sourceFinder.get(dynamic); if (value == null) { return dynamic; // Source doesn't exist, nothing to copy @@ -1677,6 +1742,7 @@ public static TypeRewriteRule copyField(@NotNull final DynamicOps ops, return setAtPath(dynamic, targetPath, value); }); + return withFieldOps(base, List.of(FieldOperation.copy(sourcePath, targetPath)), ruleName); } // ==================== Path-Based Combinators ==================== @@ -1717,7 +1783,9 @@ public static TypeRewriteRule transformFieldAt(@NotNull final DynamicOps Preconditions.checkNotNull(transform, "transform must not be null"); final Finder finder = parsePath(path); - return updateAt("transformFieldAt(" + path + ")", ops, finder, transform); + final String ruleName = "transformFieldAt(" + path + ")"; + final TypeRewriteRule base = updateAt(ruleName, ops, finder, transform); + return withFieldOps(base, List.of(FieldOperation.transformPath(path)), ruleName); } /** @@ -1765,7 +1833,8 @@ public static TypeRewriteRule renameFieldAt(@NotNull final DynamicOps ops final String oldName = path.substring(lastDot + 1); final Finder parentFinder = parsePath(parentPath); - return dynamicTransform("renameFieldAt(" + path + " -> " + newName + ")", ops, dynamic -> { + final String ruleName = "renameFieldAt(" + path + " -> " + newName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { final Dynamic parent = parentFinder.get(dynamic); if (parent == null) { return dynamic; @@ -1782,6 +1851,7 @@ public static TypeRewriteRule renameFieldAt(@NotNull final DynamicOps ops final Dynamic updatedParent = typedParent.remove(oldName).set(newName, typedValue); return parentFinder.set(dynamic, updatedParent); }); + return withFieldOps(base, List.of(FieldOperation.renamePath(path, newName)), ruleName); } /** @@ -1812,8 +1882,10 @@ public static TypeRewriteRule removeFieldAt(@NotNull final DynamicOps ops Preconditions.checkNotNull(ops, "ops must not be null"); Preconditions.checkNotNull(path, "path must not be null"); - return dynamicTransform("removeFieldAt(" + path + ")", ops, + final String ruleName = "removeFieldAt(" + path + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> removeAtPath(dynamic, path)); + return withFieldOps(base, List.of(FieldOperation.removePath(path)), ruleName); } /** @@ -1852,13 +1924,15 @@ public static TypeRewriteRule addFieldAt(@NotNull final DynamicOps ops, final Finder finder = parsePath(path); - return dynamicTransform("addFieldAt(" + path + ")", ops, dynamic -> { + final String ruleName = "addFieldAt(" + path + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { // Only add if field doesn't exist if (finder.get(dynamic) != null) { return dynamic; } return setAtPath(dynamic, path, defaultValue); }); + return withFieldOps(base, List.of(FieldOperation.addPath(path)), ruleName); } // ==================== Conditional Combinators ==================== @@ -1895,7 +1969,8 @@ public static TypeRewriteRule ifFieldExists(@NotNull final DynamicOps ops Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(rule, "rule must not be null"); - return new TypeRewriteRule() { + final String ruleName = "ifFieldExists(" + fieldName + ", " + rule + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @NotNull public Optional> rewrite(@NotNull final Type type, @@ -1916,9 +1991,10 @@ public Optional> rewrite(@NotNull final Type type, @Override public String toString() { - return "ifFieldExists(" + fieldName + ", " + rule + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "exists")), ruleName); } /** @@ -1950,7 +2026,8 @@ public static TypeRewriteRule ifFieldMissing(@NotNull final DynamicOps op Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(rule, "rule must not be null"); - return new TypeRewriteRule() { + final String ruleName = "ifFieldMissing(" + fieldName + ", " + rule + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @NotNull public Optional> rewrite(@NotNull final Type type, @@ -1971,9 +2048,10 @@ public Optional> rewrite(@NotNull final Type type, @Override public String toString() { - return "ifFieldMissing(" + fieldName + ", " + rule + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "missing")), ruleName); } /** @@ -2010,7 +2088,8 @@ public static TypeRewriteRule ifFieldEquals(@NotNull final DynamicOps Preconditions.checkNotNull(value, "value must not be null"); Preconditions.checkNotNull(rule, "rule must not be null"); - return new TypeRewriteRule() { + final String ruleName = "ifFieldEquals(" + fieldName + " == " + value + ", " + rule + ")"; + final TypeRewriteRule base = new TypeRewriteRule() { @Override @NotNull public Optional> rewrite(@NotNull final Type type, @@ -2051,9 +2130,10 @@ public Optional> rewrite(@NotNull final Type type, @Override public String toString() { - return "ifFieldEquals(" + fieldName + " == " + value + ", " + rule + ")"; + return ruleName; } }; + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "equals")), ruleName); } // ==================== Single-Pass Conditional Combinators ==================== @@ -2136,13 +2216,15 @@ public static TypeRewriteRule ifFieldExists( Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return dynamicTransform("ifFieldExists(" + fieldName + ")", ops, dynamic -> { + final String ruleName = "ifFieldExists(" + fieldName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; if (typedDynamic.get(fieldName) != null) { return transform.apply(typedDynamic); } return dynamic; }); + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "exists")), ruleName); } /** @@ -2181,13 +2263,15 @@ public static TypeRewriteRule ifFieldMissing( Preconditions.checkNotNull(fieldName, "fieldName must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return dynamicTransform("ifFieldMissing(" + fieldName + ")", ops, dynamic -> { + final String ruleName = "ifFieldMissing(" + fieldName + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; if (typedDynamic.get(fieldName) == null) { return transform.apply(typedDynamic); } return dynamic; }); + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "missing")), ruleName); } /** @@ -2231,7 +2315,8 @@ public static TypeRewriteRule ifFieldEquals( Preconditions.checkNotNull(value, "value must not be null"); Preconditions.checkNotNull(transform, "transform must not be null"); - return dynamicTransform("ifFieldEquals(" + fieldName + " == " + value + ")", ops, dynamic -> { + final String ruleName = "ifFieldEquals(" + fieldName + " == " + value + ")"; + final TypeRewriteRule base = dynamicTransform(ruleName, ops, dynamic -> { @SuppressWarnings("unchecked") final Dynamic typedDynamic = (Dynamic) dynamic; final Dynamic field = typedDynamic.get(fieldName); @@ -2246,6 +2331,7 @@ public static TypeRewriteRule ifFieldEquals( } return dynamic; }); + return withFieldOps(base, List.of(FieldOperation.conditional(fieldName, "equals")), ruleName); } /** @@ -2275,7 +2361,44 @@ private static boolean matchesValue(@NotNull final Dynamic field, @Not return false; } - // ==================== Private Helpers ==================== + // ==================== Field-Aware Wrapper ==================== + + /** + * Wraps a {@link TypeRewriteRule} with field-level metadata. + * + * @param rule the rule to wrap, must not be {@code null} + * @param fieldOperations the field operations metadata, must not be {@code null} + * @param name the display name for the wrapped rule, must not be {@code null} + * @return a field-aware rule wrapping the delegate + * @since 1.0.0 + */ + @NotNull + private static TypeRewriteRule withFieldOps(@NotNull final TypeRewriteRule rule, + @NotNull final List fieldOperations, + @NotNull final String name) { + return new FieldAwareTypeRewriteRule(rule, fieldOperations, name); + } + + /** + * Collects field operation metadata from all child rules that implement {@link FieldAwareRule}. + * + *

    This is used by composition methods ({@link #seq}, {@link #seqAll}, {@link #choice}) + * to aggregate field-level metadata from their children into the composed rule.

    + * + * @param rules the child rules to inspect + * @return an unmodifiable list of aggregated field operations; empty if no children are field-aware + * @since 1.0.0 + */ + @NotNull + private static List collectFieldOperations(@NotNull final TypeRewriteRule... rules) { + final List result = new ArrayList<>(); + for (final TypeRewriteRule rule : rules) { + if (rule instanceof FieldAwareRule fieldAware) { + result.addAll(fieldAware.fieldOperations()); + } + } + return List.copyOf(result); + } /** * Parses a dot-notation path into a composed Finder. @@ -2298,6 +2421,8 @@ private static Finder parsePath(@NotNull final String path) { return PATH_CACHE.computeIfAbsent(path, Rules::parsePathInternal); } + // ==================== Private Helpers ==================== + /** * Internal method that parses a path without caching. Uses character-based parsing for better performance than * regex. @@ -2451,8 +2576,6 @@ private static Dynamic setAtPathRecursive(@NotNull final Dynamic return dynamic.set(part, updatedChild); } - // ==================== Noop and Debug ==================== - /** * Creates the identity rule. * @@ -2463,6 +2586,8 @@ public static TypeRewriteRule noop() { return TypeRewriteRule.identity(); } + // ==================== Noop and Debug ==================== + /** * Creates a rule that logs when applied using the default System.out logger. * @@ -2528,4 +2653,69 @@ public String toString() { } }; } + + /** + * A {@link TypeRewriteRule} wrapper that also implements {@link FieldAwareRule}, providing structured field-level + * metadata for diagnostic purposes. + * + *

    This wrapper is used internally by the field operation factory methods + * (e.g., {@link #renameField}, {@link #removeField}) to annotate the returned rules with metadata about which + * fields they affect.

    + * + * @param delegate the underlying rule that performs the actual rewrite logic, must not be {@code null} + * @param fieldOperations immutable list of field-level operation metadata describing which fields this rule + * affects, must not be {@code null} + * @param name display name for this rule, used in {@link #toString()} and diagnostic output, must not be + * {@code null} + * @since 1.0.0 + */ + private record FieldAwareTypeRewriteRule( + @NotNull TypeRewriteRule delegate, + @NotNull List fieldOperations, + @NotNull String name + ) implements TypeRewriteRule, FieldAwareRule { + + /** + * Creates a new field-aware rule wrapper. + * + * @param delegate the underlying rule to delegate rewrite logic to, must not be {@code null} + * @param fieldOperations the field-level operation metadata, must not be {@code null} + * @param name the display name for this rule, must not be {@code null} + * @throws NullPointerException if any argument is {@code null} + */ + private FieldAwareTypeRewriteRule { + Preconditions.checkNotNull(delegate, "delegate must not be null"); + Preconditions.checkNotNull(fieldOperations, "fieldOperations must not be null"); + Preconditions.checkNotNull(name, "name must not be null"); + fieldOperations = List.copyOf(fieldOperations); + } + + /** + * {@inheritDoc} + * + *

    Delegates to the underlying rule's rewrite logic.

    + * + * @param type the type descriptor of the input, must not be {@code null} + * @param input the typed value to potentially rewrite, must not be {@code null} + * @return an {@link Optional} containing the rewritten value if this rule applies, or {@link Optional#empty()} + * if the rule doesn't match; never {@code null} + */ + @Override + @NotNull + public Optional> rewrite(@NotNull final Type type, + @NotNull final Typed input) { + return this.delegate.rewrite(type, input); + } + + /** + * Returns the display name of this rule. + * + * @return the rule name, never {@code null} + */ + @Override + @NotNull + public String toString() { + return this.name; + } + } } diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java index 1c879e9..394a42c 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/package-info.java @@ -34,6 +34,8 @@ * are composable and can be combined to create complex migrations. *
  • {@link de.splatgames.aether.datafixers.api.rewrite.Rules} - Factory class * providing common rewrite rule combinators for typical data transformations.
  • + *
  • {@link de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule} - Marker + * interface for rules that carry structured field-level metadata for diagnostics.
  • * * *

    Common Rules

    diff --git a/aether-datafixers-api/src/test/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRulesTest.java b/aether-datafixers-api/src/test/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRulesTest.java new file mode 100644 index 0000000..da244e5 --- /dev/null +++ b/aether-datafixers-api/src/test/java/de/splatgames/aether/datafixers/api/rewrite/FieldAwareRulesTest.java @@ -0,0 +1,312 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.api.rewrite; + +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.optic.TestOps; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests that {@link Rules} factory methods produce {@link FieldAwareRule} instances + * with correct field operation metadata, and that composition methods properly + * aggregate field operations from their children. + */ +@DisplayName("FieldAwareRule integration with Rules") +class FieldAwareRulesTest { + + private static final TestOps OPS = TestOps.INSTANCE; + + // ==================== Helper Methods ==================== + + /** + * Asserts that the given rule is a {@link FieldAwareRule} and returns it. + */ + private static FieldAwareRule assertFieldAware(final TypeRewriteRule rule) { + assertThat(rule).isInstanceOf(FieldAwareRule.class); + return (FieldAwareRule) rule; + } + + /** + * Asserts that the given rule is a {@link FieldAwareRule} with the expected + * number of field operations. + */ + private static List assertFieldOpsCount(final TypeRewriteRule rule, + final int expectedCount) { + final FieldAwareRule fieldAware = assertFieldAware(rule); + final List ops = fieldAware.fieldOperations(); + assertThat(ops).hasSize(expectedCount); + return ops; + } + + // ==================== Individual Field Rules ==================== + + @Nested + @DisplayName("Individual Field Rules") + class IndividualFieldRules { + + @Test + @DisplayName("renameField should produce FieldAwareRule with 1 RENAME operation") + void renameFieldShouldProduceRenameOperation() { + final TypeRewriteRule rule = Rules.renameField(OPS, "old", "new"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(0).fieldPath()).containsExactly("old"); + assertThat(ops.get(0).targetFieldName()).isEqualTo("new"); + } + + @Test + @DisplayName("removeField should produce FieldAwareRule with 1 REMOVE operation") + void removeFieldShouldProduceRemoveOperation() { + final TypeRewriteRule rule = Rules.removeField(OPS, "field"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.REMOVE); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("addField should produce FieldAwareRule with 1 ADD operation") + void addFieldShouldProduceAddOperation() { + final Dynamic defaultVal = new Dynamic<>(OPS, 42); + final TypeRewriteRule rule = Rules.addField(OPS, "field", defaultVal); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.ADD); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("transformField should produce FieldAwareRule with 1 TRANSFORM operation") + void transformFieldShouldProduceTransformOperation() { + final TypeRewriteRule rule = Rules.transformField(OPS, "field", + d -> d.createInt(d.asInt().result().orElse(0) + 1)); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.TRANSFORM); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("setField should produce FieldAwareRule with 1 SET operation") + void setFieldShouldProduceSetOperation() { + final Dynamic value = new Dynamic<>(OPS, "value"); + final TypeRewriteRule rule = Rules.setField(OPS, "field", value); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.SET); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + } + + @Test + @DisplayName("renameFields should produce FieldAwareRule with 2 RENAME operations") + void renameFieldsShouldProduceMultipleRenameOperations() { + final TypeRewriteRule rule = Rules.renameFields(OPS, Map.of("a", "b", "c", "d")); + + final List ops = assertFieldOpsCount(rule, 2); + assertThat(ops).allMatch(op -> op.operationType() == FieldOperationType.RENAME); + } + + @Test + @DisplayName("removeFields should produce FieldAwareRule with 2 REMOVE operations") + void removeFieldsShouldProduceMultipleRemoveOperations() { + final TypeRewriteRule rule = Rules.removeFields(OPS, "a", "b"); + + final List ops = assertFieldOpsCount(rule, 2); + assertThat(ops).allMatch(op -> op.operationType() == FieldOperationType.REMOVE); + } + + @Test + @DisplayName("moveField should produce FieldAwareRule with 1 MOVE operation") + void moveFieldShouldProduceMoveOperation() { + final TypeRewriteRule rule = Rules.moveField(OPS, "a", "b"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.MOVE); + assertThat(ops.get(0).fieldPath()).containsExactly("a"); + assertThat(ops.get(0).targetFieldName()).isEqualTo("b"); + } + + @Test + @DisplayName("copyField should produce FieldAwareRule with 1 COPY operation") + void copyFieldShouldProduceCopyOperation() { + final TypeRewriteRule rule = Rules.copyField(OPS, "a", "b"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.COPY); + assertThat(ops.get(0).fieldPath()).containsExactly("a"); + assertThat(ops.get(0).targetFieldName()).isEqualTo("b"); + } + + @Test + @DisplayName("groupFields should produce FieldAwareRule with 1 GROUP operation") + void groupFieldsShouldProduceGroupOperation() { + final TypeRewriteRule rule = Rules.groupFields(OPS, "pos", "x", "y"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.GROUP); + assertThat(ops.get(0).targetFieldName()).isEqualTo("pos"); + } + + @Test + @DisplayName("flattenField should produce FieldAwareRule with 1 FLATTEN operation") + void flattenFieldShouldProduceFlattenOperation() { + final TypeRewriteRule rule = Rules.flattenField(OPS, "pos"); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.FLATTEN); + assertThat(ops.get(0).fieldPath()).containsExactly("pos"); + } + + @Test + @DisplayName("ifFieldExists (3-arg) should produce FieldAwareRule with 1 CONDITIONAL operation") + void ifFieldExistsShouldProduceConditionalOperation() { + final TypeRewriteRule someRule = Rules.removeField(OPS, "field"); + final TypeRewriteRule rule = Rules.ifFieldExists(OPS, "field", someRule); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.CONDITIONAL); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + assertThat(ops.get(0).description()).isEqualTo("exists"); + } + + @Test + @DisplayName("ifFieldMissing (3-arg) should produce FieldAwareRule with 1 CONDITIONAL operation") + void ifFieldMissingShouldProduceConditionalOperation() { + final TypeRewriteRule someRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule rule = Rules.ifFieldMissing(OPS, "field", someRule); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.CONDITIONAL); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + assertThat(ops.get(0).description()).isEqualTo("missing"); + } + + @Test + @DisplayName("ifFieldEquals (4-arg) should produce FieldAwareRule with 1 CONDITIONAL operation") + void ifFieldEqualsShouldProduceConditionalOperation() { + final TypeRewriteRule someRule = Rules.removeField(OPS, "field"); + final TypeRewriteRule rule = Rules.ifFieldEquals(OPS, "field", 1, someRule); + + final List ops = assertFieldOpsCount(rule, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.CONDITIONAL); + assertThat(ops.get(0).fieldPath()).containsExactly("field"); + assertThat(ops.get(0).description()).isEqualTo("equals"); + } + } + + // ==================== Composition Aggregation ==================== + + @Nested + @DisplayName("Composition Aggregation") + class CompositionAggregation { + + @Test + @DisplayName("seq should aggregate field operations from children") + void seqShouldAggregateFieldOperations() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule addRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule composed = Rules.seq(renameRule, addRule); + + final List ops = assertFieldOpsCount(composed, 2); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(1).operationType()).isEqualTo(FieldOperationType.ADD); + } + + @Test + @DisplayName("seqAll should aggregate field operations from all children") + void seqAllShouldAggregateFieldOperations() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule removeRule = Rules.removeField(OPS, "deprecated"); + final TypeRewriteRule addRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule composed = Rules.seqAll(renameRule, removeRule, addRule); + + final List ops = assertFieldOpsCount(composed, 3); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(1).operationType()).isEqualTo(FieldOperationType.REMOVE); + assertThat(ops.get(2).operationType()).isEqualTo(FieldOperationType.ADD); + } + + @Test + @DisplayName("choice should aggregate field operations from children") + void choiceShouldAggregateFieldOperations() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule addRule = Rules.addField(OPS, "field", new Dynamic<>(OPS, 1)); + final TypeRewriteRule composed = Rules.choice(renameRule, addRule); + + final List ops = assertFieldOpsCount(composed, 2); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(ops.get(1).operationType()).isEqualTo(FieldOperationType.ADD); + } + + @Test + @DisplayName("seq with identity should only include field-aware operations") + void seqWithIdentityShouldOnlyIncludeFieldAwareOps() { + final TypeRewriteRule renameRule = Rules.renameField(OPS, "old", "new"); + final TypeRewriteRule composed = Rules.seq(renameRule, TypeRewriteRule.identity()); + + final List ops = assertFieldOpsCount(composed, 1); + assertThat(ops.get(0).operationType()).isEqualTo(FieldOperationType.RENAME); + } + + @Test + @DisplayName("seq of pure non-field-aware rules should not be FieldAwareRule") + void seqOfNonFieldAwareRulesShouldNotBeFieldAware() { + final TypeRewriteRule composed = Rules.seq( + TypeRewriteRule.identity(), + TypeRewriteRule.identity() + ); + + assertThat(composed).isNotInstanceOf(FieldAwareRule.class); + } + } + + // ==================== Batch Operations ==================== + + @Nested + @DisplayName("Batch Operations") + class BatchOperations { + + @Test + @DisplayName("batch should produce FieldAwareRule with aggregated operations") + void batchShouldProduceFieldAwareRuleWithAggregatedOps() { + final TypeRewriteRule rule = Rules.batch(OPS, b -> b + .rename("a", "b") + .remove("c") + ); + + final List ops = assertFieldOpsCount(rule, 2); + assertThat(ops).extracting(FieldOperation::operationType) + .containsExactlyInAnyOrder(FieldOperationType.RENAME, FieldOperationType.REMOVE); + } + } +} diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapper.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapper.java index 8526ec5..4fdc085 100644 --- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapper.java +++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapper.java @@ -24,7 +24,9 @@ import com.google.common.base.Preconditions; import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; +import de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule; import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; import de.splatgames.aether.datafixers.api.type.Type; import de.splatgames.aether.datafixers.api.type.Typed; @@ -32,6 +34,7 @@ import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Optional; /** @@ -59,7 +62,14 @@ */ public final class DiagnosticRuleWrapper implements TypeRewriteRule { + /** + * The underlying rule that performs the actual rewrite logic. + */ private final TypeRewriteRule delegate; + + /** + * The diagnostic context used for recording rule application events. + */ private final DiagnosticContext context; /** @@ -80,6 +90,20 @@ public DiagnosticRuleWrapper( this.context = context; } + /** + * {@inheritDoc} + * + *

    Intercepts the rewrite call to capture diagnostic information including + * timing, match status, and field-level operation metadata from + * {@link FieldAwareRule} delegates. If {@code captureRuleDetails} is disabled, + * delegates directly without recording.

    + * + * @param type the type descriptor of the input, must not be {@code null} + * @param input the typed value to potentially rewrite, must not be {@code null} + * @return an {@link Optional} containing the rewritten value if the rule applies, + * or {@link Optional#empty()} if it doesn't match; never {@code null} + * @throws NullPointerException if {@code type} or {@code input} is {@code null} + */ @Override @NotNull public Optional> rewrite( @@ -98,13 +122,22 @@ public Optional> rewrite( final Optional> result = this.delegate.rewrite(type, input); final Duration duration = Duration.ofNanos(System.nanoTime() - startNano); + final List fieldOps; + if (this.context.options().captureFieldDetails() + && this.delegate instanceof FieldAwareRule fieldAware) { + fieldOps = fieldAware.fieldOperations(); + } else { + fieldOps = List.of(); + } + final RuleApplication application = new RuleApplication( this.delegate.toString(), type.describe(), timestamp, duration, result.isPresent(), - null + null, + fieldOps ); this.context.reportBuilder().recordRuleApplication(application); @@ -112,6 +145,16 @@ public Optional> rewrite( return result; } + /** + * {@inheritDoc} + * + *

    Delegates to {@link #rewrite(Type, Typed)} to ensure diagnostics are captured, + * returning the original input if the rule doesn't match.

    + * + * @param input the typed value to transform, must not be {@code null} + * @return the rewritten result if matched, or the original input unchanged; never {@code null} + * @throws NullPointerException if {@code input} is {@code null} + */ @Override @NotNull public Typed apply(@NotNull final Typed input) { @@ -120,6 +163,17 @@ public Typed apply(@NotNull final Typed input) { return this.rewrite(input.type(), input).orElse(input); } + /** + * {@inheritDoc} + * + *

    Delegates to {@link #rewrite(Type, Typed)} to ensure diagnostics are captured, + * throwing if the rule doesn't match.

    + * + * @param input the typed value to transform, must not be {@code null} + * @return the rewritten result, never {@code null} + * @throws IllegalStateException if the rule doesn't match the input type + * @throws NullPointerException if {@code input} is {@code null} + */ @Override @NotNull public Typed applyOrThrow(@NotNull final Typed input) { @@ -130,6 +184,16 @@ public Typed applyOrThrow(@NotNull final Typed input) { )); } + /** + * {@inheritDoc} + * + *

    Composes this rule with the next rule, wrapping the result in a new + * {@link DiagnosticRuleWrapper} to maintain diagnostic capture across compositions.

    + * + * @param next the rule to apply after this rule succeeds, must not be {@code null} + * @return a composed diagnostic rule applying both rules in sequence, never {@code null} + * @throws NullPointerException if {@code next} is {@code null} + */ @Override @NotNull public TypeRewriteRule andThen(@NotNull final TypeRewriteRule next) { @@ -138,6 +202,16 @@ public TypeRewriteRule andThen(@NotNull final TypeRewriteRule next) { return new DiagnosticRuleWrapper(this.delegate.andThen(unwrap(next)), this.context); } + /** + * {@inheritDoc} + * + *

    Creates a fallback composition, wrapping the result in a new + * {@link DiagnosticRuleWrapper} to maintain diagnostic capture.

    + * + * @param fallback the rule to try if this rule doesn't match, must not be {@code null} + * @return a composed diagnostic rule with fallback behavior, never {@code null} + * @throws NullPointerException if {@code fallback} is {@code null} + */ @Override @NotNull public TypeRewriteRule orElse(@NotNull final TypeRewriteRule fallback) { @@ -145,12 +219,30 @@ public TypeRewriteRule orElse(@NotNull final TypeRewriteRule fallback) { return new DiagnosticRuleWrapper(this.delegate.orElse(unwrap(fallback)), this.context); } + /** + * {@inheritDoc} + * + *

    Makes this rule always succeed, wrapping the result in a new + * {@link DiagnosticRuleWrapper} to maintain diagnostic capture.

    + * + * @return a diagnostic rule that always succeeds, never {@code null} + */ @Override @NotNull public TypeRewriteRule orKeep() { return new DiagnosticRuleWrapper(this.delegate.orKeep(), this.context); } + /** + * {@inheritDoc} + * + *

    Adds a type filter, wrapping the result in a new {@link DiagnosticRuleWrapper} + * to maintain diagnostic capture.

    + * + * @param targetType the type that must match for the rule to apply, must not be {@code null} + * @return a filtered diagnostic rule, never {@code null} + * @throws NullPointerException if {@code targetType} is {@code null} + */ @Override @NotNull public TypeRewriteRule ifType(@NotNull final Type targetType) { @@ -158,6 +250,16 @@ public TypeRewriteRule ifType(@NotNull final Type targetType) { return new DiagnosticRuleWrapper(this.delegate.ifType(targetType), this.context); } + /** + * {@inheritDoc} + * + *

    Adds a name for debugging, wrapping the result in a new {@link DiagnosticRuleWrapper} + * to maintain diagnostic capture.

    + * + * @param name a descriptive name for this rule, must not be {@code null} + * @return a named diagnostic rule, never {@code null} + * @throws NullPointerException if {@code name} is {@code null} + */ @Override @NotNull public TypeRewriteRule named(@NotNull final String name) { @@ -165,6 +267,11 @@ public TypeRewriteRule named(@NotNull final String name) { return new DiagnosticRuleWrapper(this.delegate.named(name), this.context); } + /** + * Returns the string representation of the underlying delegate rule. + * + * @return the delegate rule's string representation, never {@code null} + */ @Override public String toString() { return this.delegate.toString(); diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRecordsTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRecordsTest.java index 32d4f64..634e9d0 100644 --- a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRecordsTest.java +++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRecordsTest.java @@ -58,7 +58,8 @@ void createsRecordWithAllFields() { now, duration, true, - "Renamed playerName to name" + "Renamed playerName to name", + List.of() ); assertThat(rule.ruleName()).isEqualTo("rename_field"); @@ -94,7 +95,7 @@ void ofFactoryCreatesRecordWithoutDescription() { @DisplayName("descriptionOpt() returns Optional with description") void descriptionOptReturnsOptionalWithDescription() { RuleApplication rule = new RuleApplication( - "rule", "type", Instant.now(), Duration.ZERO, true, "desc" + "rule", "type", Instant.now(), Duration.ZERO, true, "desc", List.of() ); assertThat(rule.descriptionOpt()).contains("desc"); @@ -127,13 +128,13 @@ void toSummaryReturnsFormattedString() { @Test @DisplayName("throws NullPointerException for null required fields") void throwsNullPointerExceptionForNullRequiredFields() { - assertThatThrownBy(() -> new RuleApplication(null, "type", Instant.now(), Duration.ZERO, true, null)) + assertThatThrownBy(() -> new RuleApplication(null, "type", Instant.now(), Duration.ZERO, true, null, List.of())) .isInstanceOf(NullPointerException.class); - assertThatThrownBy(() -> new RuleApplication("rule", null, Instant.now(), Duration.ZERO, true, null)) + assertThatThrownBy(() -> new RuleApplication("rule", null, Instant.now(), Duration.ZERO, true, null, List.of())) .isInstanceOf(NullPointerException.class); - assertThatThrownBy(() -> new RuleApplication("rule", "type", null, Duration.ZERO, true, null)) + assertThatThrownBy(() -> new RuleApplication("rule", "type", null, Duration.ZERO, true, null, List.of())) .isInstanceOf(NullPointerException.class); - assertThatThrownBy(() -> new RuleApplication("rule", "type", Instant.now(), null, true, null)) + assertThatThrownBy(() -> new RuleApplication("rule", "type", Instant.now(), null, true, null, List.of())) .isInstanceOf(NullPointerException.class); } } diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapperTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapperTest.java index 8fbd527..1a89500 100644 --- a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapperTest.java +++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/DiagnosticRuleWrapperTest.java @@ -24,15 +24,21 @@ import de.splatgames.aether.datafixers.api.TypeReference; import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; +import de.splatgames.aether.datafixers.api.rewrite.FieldAwareRule; import de.splatgames.aether.datafixers.api.rewrite.Rules; import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; import de.splatgames.aether.datafixers.api.type.Type; import de.splatgames.aether.datafixers.api.type.Typed; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -308,6 +314,143 @@ void delegatesToUnderlyingRule() { } } + @Nested + @DisplayName("Field-Level Metadata") + class FieldLevelMetadata { + + @Test + @DisplayName("extracts field operations from FieldAwareRule delegate") + void extractsFieldOperationsFromFieldAwareRule() { + // Create a FieldAwareRule via a field-aware rule factory method + TypeRewriteRule fieldAwareRule = new TypeRewriteRule() { + @NotNull + @Override + public Optional> rewrite(@NotNull final Type type, + @NotNull final Typed input) { + return Optional.of(input); + } + }; + // Wrap it manually as FieldAwareRule + TypeRewriteRule delegate = createFieldAwareDelegate( + List.of(FieldOperation.rename("old", "new")) + ); + + context.reportBuilder().startMigration(PLAYER, new de.splatgames.aether.datafixers.api.DataVersion(1), + new de.splatgames.aether.datafixers.api.DataVersion(2)); + context.reportBuilder().startFix(createMockFix()); + + DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegate, context); + wrapper.rewrite(testType, testInput); + + final var mockFix = createMockFix(); + context.reportBuilder().endFix(mockFix, java.time.Duration.ofMillis(1), null); + + List ruleApps = context.reportBuilder() + .build().fixExecutions().get(0).ruleApplications(); + assertThat(ruleApps).hasSize(1); + assertThat(ruleApps.get(0).fieldOperations()).hasSize(1); + assertThat(ruleApps.get(0).fieldOperations().get(0).operationType()) + .isEqualTo(FieldOperationType.RENAME); + } + + @Test + @DisplayName("returns empty field operations when captureFieldDetails is false") + void returnsEmptyFieldOpsWhenCaptureFieldDetailsDisabled() { + DiagnosticContextImpl noFieldDetailsCtx = new DiagnosticContextImpl( + DiagnosticOptions.builder() + .captureRuleDetails(true) + .captureFieldDetails(false) + .build() + ); + TypeRewriteRule delegate = createFieldAwareDelegate( + List.of(FieldOperation.rename("old", "new")) + ); + + noFieldDetailsCtx.reportBuilder().startMigration(PLAYER, + new de.splatgames.aether.datafixers.api.DataVersion(1), + new de.splatgames.aether.datafixers.api.DataVersion(2)); + noFieldDetailsCtx.reportBuilder().startFix(createMockFix()); + + DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegate, noFieldDetailsCtx); + wrapper.rewrite(testType, testInput); + + final var mockFix = createMockFix(); + noFieldDetailsCtx.reportBuilder().endFix(mockFix, java.time.Duration.ofMillis(1), null); + + List ruleApps = noFieldDetailsCtx.reportBuilder() + .build().fixExecutions().get(0).ruleApplications(); + assertThat(ruleApps).hasSize(1); + assertThat(ruleApps.get(0).fieldOperations()).isEmpty(); + } + + @Test + @DisplayName("returns empty field operations for non-FieldAwareRule delegate") + void returnsEmptyFieldOpsForNonFieldAwareDelegate() { + context.reportBuilder().startMigration(PLAYER, + new de.splatgames.aether.datafixers.api.DataVersion(1), + new de.splatgames.aether.datafixers.api.DataVersion(2)); + final var mockFix = createMockFix(); + context.reportBuilder().startFix(mockFix); + + DiagnosticRuleWrapper wrapper = new DiagnosticRuleWrapper(delegateRule, context); + wrapper.rewrite(testType, testInput); + + context.reportBuilder().endFix(mockFix, java.time.Duration.ofMillis(1), null); + + List ruleApps = context.reportBuilder() + .build().fixExecutions().get(0).ruleApplications(); + assertThat(ruleApps).hasSize(1); + assertThat(ruleApps.get(0).fieldOperations()).isEmpty(); + } + + private TypeRewriteRule createFieldAwareDelegate(List ops) { + return new FieldAwareTestRule(ops); + } + + private de.splatgames.aether.datafixers.api.fix.DataFix createMockFix() { + return new de.splatgames.aether.datafixers.api.fix.DataFix<>() { + @Override + public @NotNull String name() { return "test-fix"; } + @Override + public @NotNull de.splatgames.aether.datafixers.api.DataVersion fromVersion() { + return new de.splatgames.aether.datafixers.api.DataVersion(1); + } + @Override + public @NotNull de.splatgames.aether.datafixers.api.DataVersion toVersion() { + return new de.splatgames.aether.datafixers.api.DataVersion(2); + } + @Override + public @NotNull de.splatgames.aether.datafixers.api.dynamic.Dynamic apply( + @NotNull TypeReference type, + @NotNull de.splatgames.aether.datafixers.api.dynamic.Dynamic input, + @NotNull de.splatgames.aether.datafixers.api.fix.DataFixerContext ctx) { + return input; + } + }; + } + } + + /** + * A test implementation of both TypeRewriteRule and FieldAwareRule. + */ + private static class FieldAwareTestRule implements TypeRewriteRule, FieldAwareRule { + private final List fieldOperations; + + FieldAwareTestRule(List fieldOperations) { + this.fieldOperations = List.copyOf(fieldOperations); + } + + @Override + public @NotNull Optional> rewrite(@NotNull Type type, @NotNull Typed input) { + return Optional.of(input); + } + + @Override + public @NotNull List fieldOperations() { + return this.fieldOperations; + } + } + @Nested @DisplayName("wrap()") class Wrap { diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTest.java new file mode 100644 index 0000000..3b0c34a --- /dev/null +++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTest.java @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.core.diagnostic; + +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link FieldOperation}. + */ +@DisplayName("FieldOperation") +class FieldOperationTest { + + // ------------------------------------------------------------------------- + // Constructor Validation + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Constructor Validation") + class ConstructorValidation { + + @Test + @DisplayName("throws NullPointerException when operationType is null") + void throwsWhenOperationTypeIsNull() { + assertThatThrownBy(() -> new FieldOperation(null, List.of("field"), null, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("operationType"); + } + + @Test + @DisplayName("throws NullPointerException when fieldPath is null") + void throwsWhenFieldPathIsNull() { + assertThatThrownBy(() -> new FieldOperation(FieldOperationType.RENAME, null, null, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("fieldPath"); + } + + @Test + @DisplayName("throws IllegalArgumentException when fieldPath is empty") + void throwsWhenFieldPathIsEmpty() { + assertThatThrownBy(() -> new FieldOperation(FieldOperationType.RENAME, List.of(), null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fieldPath"); + } + } + + // ------------------------------------------------------------------------- + // Factory Methods — Top-Level Fields + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Factory Methods — Top-Level Fields") + class TopLevelFactoryMethods { + + @Test + @DisplayName("rename() creates RENAME operation with target") + void renameCreatesCorrectOperation() { + FieldOperation op = FieldOperation.rename("old", "new"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(op.fieldPath()).containsExactly("old"); + assertThat(op.targetFieldName()).isEqualTo("new"); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("remove() creates REMOVE operation") + void removeCreatesCorrectOperation() { + FieldOperation op = FieldOperation.remove("field"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.REMOVE); + assertThat(op.fieldPath()).containsExactly("field"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("add() creates ADD operation") + void addCreatesCorrectOperation() { + FieldOperation op = FieldOperation.add("field"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.ADD); + assertThat(op.fieldPath()).containsExactly("field"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("transform() creates TRANSFORM operation") + void transformCreatesCorrectOperation() { + FieldOperation op = FieldOperation.transform("field"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.TRANSFORM); + assertThat(op.fieldPath()).containsExactly("field"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("set() creates SET operation") + void setCreatesCorrectOperation() { + FieldOperation op = FieldOperation.set("field"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.SET); + assertThat(op.fieldPath()).containsExactly("field"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("move() creates MOVE operation with parsed path and target") + void moveCreatesCorrectOperation() { + FieldOperation op = FieldOperation.move("a.b", "c.d"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.MOVE); + assertThat(op.fieldPath()).containsExactly("a", "b"); + assertThat(op.targetFieldName()).isEqualTo("c.d"); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("copy() creates COPY operation with parsed path and target") + void copyCreatesCorrectOperation() { + FieldOperation op = FieldOperation.copy("a.b", "c.d"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.COPY); + assertThat(op.fieldPath()).containsExactly("a", "b"); + assertThat(op.targetFieldName()).isEqualTo("c.d"); + assertThat(op.description()).isNull(); + } + } + + // ------------------------------------------------------------------------- + // Factory Methods — Nested / Path-Based Fields + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Factory Methods — Path-Based Fields") + class PathBasedFactoryMethods { + + @Test + @DisplayName("renamePath() creates RENAME with parsed path") + void renamePathCreatesCorrectOperation() { + FieldOperation op = FieldOperation.renamePath("pos.x", "posX"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.RENAME); + assertThat(op.fieldPath()).containsExactly("pos", "x"); + assertThat(op.targetFieldName()).isEqualTo("posX"); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("removePath() creates REMOVE with parsed path") + void removePathCreatesCorrectOperation() { + FieldOperation op = FieldOperation.removePath("pos.x"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.REMOVE); + assertThat(op.fieldPath()).containsExactly("pos", "x"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("addPath() creates ADD with parsed path") + void addPathCreatesCorrectOperation() { + FieldOperation op = FieldOperation.addPath("pos.w"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.ADD); + assertThat(op.fieldPath()).containsExactly("pos", "w"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("transformPath() creates TRANSFORM with parsed path") + void transformPathCreatesCorrectOperation() { + FieldOperation op = FieldOperation.transformPath("pos.x"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.TRANSFORM); + assertThat(op.fieldPath()).containsExactly("pos", "x"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + } + + // ------------------------------------------------------------------------- + // Factory Methods — Structural Operations + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Factory Methods — Structural Operations") + class StructuralFactoryMethods { + + @Test + @DisplayName("group() creates GROUP operation with source fields as path") + void groupCreatesCorrectOperation() { + FieldOperation op = FieldOperation.group("position", "x", "y", "z"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.GROUP); + assertThat(op.fieldPath()).containsExactly("x", "y", "z"); + assertThat(op.targetFieldName()).isEqualTo("position"); + assertThat(op.description()).isNull(); + } + + @Test + @DisplayName("flatten() creates FLATTEN operation") + void flattenCreatesCorrectOperation() { + FieldOperation op = FieldOperation.flatten("position"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.FLATTEN); + assertThat(op.fieldPath()).containsExactly("position"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isNull(); + } + } + + // ------------------------------------------------------------------------- + // Factory Methods — Conditional Operations + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Factory Methods — Conditional Operations") + class ConditionalFactoryMethods { + + @Test + @DisplayName("conditional() creates CONDITIONAL operation with description") + void conditionalCreatesCorrectOperation() { + FieldOperation op = FieldOperation.conditional("field", "exists"); + + assertThat(op.operationType()).isEqualTo(FieldOperationType.CONDITIONAL); + assertThat(op.fieldPath()).containsExactly("field"); + assertThat(op.targetFieldName()).isNull(); + assertThat(op.description()).isEqualTo("exists"); + } + } + + // ------------------------------------------------------------------------- + // Convenience Methods + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Convenience Methods") + class ConvenienceMethods { + + @Test + @DisplayName("fieldPathString() returns dot-notation for nested path") + void fieldPathStringReturnsDotNotation() { + FieldOperation op = FieldOperation.renamePath("position.x", "posX"); + + assertThat(op.fieldPathString()).isEqualTo("position.x"); + } + + @Test + @DisplayName("fieldPathString() returns single segment for top-level field") + void fieldPathStringReturnsSingleSegment() { + FieldOperation op = FieldOperation.remove("name"); + + assertThat(op.fieldPathString()).isEqualTo("name"); + } + + @Test + @DisplayName("isNested() returns true for multi-segment path") + void isNestedReturnsTrueForMultiSegment() { + FieldOperation op = FieldOperation.removePath("position.x"); + + assertThat(op.isNested()).isTrue(); + } + + @Test + @DisplayName("isNested() returns false for single-segment path") + void isNestedReturnsFalseForSingleSegment() { + FieldOperation op = FieldOperation.remove("name"); + + assertThat(op.isNested()).isFalse(); + } + + @Test + @DisplayName("toSummary() includes operationType and fieldPath") + void toSummaryIncludesTypeAndPath() { + FieldOperation op = FieldOperation.remove("name"); + + assertThat(op.toSummary()).contains("REMOVE").contains("name"); + } + + @Test + @DisplayName("toSummary() includes target when present") + void toSummaryIncludesTarget() { + FieldOperation op = FieldOperation.rename("old", "new"); + + assertThat(op.toSummary()).contains("RENAME").contains("old").contains("new"); + } + + @Test + @DisplayName("toSummary() includes description when present") + void toSummaryIncludesDescription() { + FieldOperation op = FieldOperation.conditional("field", "exists"); + + assertThat(op.toSummary()).contains("CONDITIONAL").contains("field").contains("exists"); + } + + @Test + @DisplayName("targetFieldNameOpt() returns Optional with value when present") + void targetFieldNameOptReturnsValueWhenPresent() { + FieldOperation op = FieldOperation.rename("old", "new"); + + assertThat(op.targetFieldNameOpt()).isPresent().contains("new"); + } + + @Test + @DisplayName("targetFieldNameOpt() returns empty Optional when absent") + void targetFieldNameOptReturnsEmptyWhenAbsent() { + FieldOperation op = FieldOperation.remove("field"); + + assertThat(op.targetFieldNameOpt()).isEmpty(); + } + + @Test + @DisplayName("descriptionOpt() returns Optional with value when present") + void descriptionOptReturnsValueWhenPresent() { + FieldOperation op = FieldOperation.conditional("field", "exists"); + + assertThat(op.descriptionOpt()).isPresent().contains("exists"); + } + + @Test + @DisplayName("descriptionOpt() returns empty Optional when absent") + void descriptionOptReturnsEmptyWhenAbsent() { + FieldOperation op = FieldOperation.remove("field"); + + assertThat(op.descriptionOpt()).isEmpty(); + } + } + + // ------------------------------------------------------------------------- + // Path Parsing + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Path Parsing") + class PathParsing { + + @Test + @DisplayName("single segment: 'name' parses to ['name']") + void singleSegmentParsesToSingleElement() { + FieldOperation op = FieldOperation.removePath("name"); + + assertThat(op.fieldPath()).containsExactly("name"); + } + + @Test + @DisplayName("two segments: 'position.x' parses to ['position', 'x']") + void twoSegmentsParseCorrectly() { + FieldOperation op = FieldOperation.removePath("position.x"); + + assertThat(op.fieldPath()).containsExactly("position", "x"); + } + + @Test + @DisplayName("deep path: 'a.b.c.d' parses to ['a', 'b', 'c', 'd']") + void deepPathParsesCorrectly() { + FieldOperation op = FieldOperation.removePath("a.b.c.d"); + + assertThat(op.fieldPath()).containsExactly("a", "b", "c", "d"); + } + } + + // ------------------------------------------------------------------------- + // Null Checks on Factory Methods + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Null Checks on Factory Methods") + class NullChecksOnFactoryMethods { + + @Test + @DisplayName("rename() throws NPE when oldName is null") + void renameThrowsWhenOldNameIsNull() { + assertThatThrownBy(() -> FieldOperation.rename(null, "new")) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("rename() throws NPE when newName is null") + void renameThrowsWhenNewNameIsNull() { + assertThatThrownBy(() -> FieldOperation.rename("old", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("remove() throws NPE when fieldName is null") + void removeThrowsWhenFieldNameIsNull() { + assertThatThrownBy(() -> FieldOperation.remove(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("add() throws NPE when fieldName is null") + void addThrowsWhenFieldNameIsNull() { + assertThatThrownBy(() -> FieldOperation.add(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("transform() throws NPE when fieldName is null") + void transformThrowsWhenFieldNameIsNull() { + assertThatThrownBy(() -> FieldOperation.transform(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("set() throws NPE when fieldName is null") + void setThrowsWhenFieldNameIsNull() { + assertThatThrownBy(() -> FieldOperation.set(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("move() throws NPE when sourcePath is null") + void moveThrowsWhenSourcePathIsNull() { + assertThatThrownBy(() -> FieldOperation.move(null, "target")) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("move() throws NPE when targetPath is null") + void moveThrowsWhenTargetPathIsNull() { + assertThatThrownBy(() -> FieldOperation.move("source", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("copy() throws NPE when sourcePath is null") + void copyThrowsWhenSourcePathIsNull() { + assertThatThrownBy(() -> FieldOperation.copy(null, "target")) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("conditional() throws NPE when fieldName is null") + void conditionalThrowsWhenFieldNameIsNull() { + assertThatThrownBy(() -> FieldOperation.conditional(null, "exists")) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("conditional() throws NPE when conditionType is null") + void conditionalThrowsWhenConditionTypeIsNull() { + assertThatThrownBy(() -> FieldOperation.conditional("field", null)) + .isInstanceOf(NullPointerException.class); + } + } +} diff --git a/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTypeTest.java b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTypeTest.java new file mode 100644 index 0000000..5f42316 --- /dev/null +++ b/aether-datafixers-core/src/test/java/de/splatgames/aether/datafixers/core/diagnostic/FieldOperationTypeTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.core.diagnostic; + +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperationType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link FieldOperationType}. + */ +@DisplayName("FieldOperationType") +class FieldOperationTypeTest { + + @Nested + @DisplayName("Enum Constants") + class EnumConstants { + + @Test + @DisplayName("has exactly 10 values") + void hasExactlyTenValues() { + assertThat(FieldOperationType.values()).hasSize(10); + } + + @Test + @DisplayName("contains all expected constants") + void containsAllExpectedConstants() { + assertThat(FieldOperationType.values()).containsExactly( + FieldOperationType.RENAME, + FieldOperationType.REMOVE, + FieldOperationType.ADD, + FieldOperationType.TRANSFORM, + FieldOperationType.SET, + FieldOperationType.MOVE, + FieldOperationType.COPY, + FieldOperationType.GROUP, + FieldOperationType.FLATTEN, + FieldOperationType.CONDITIONAL + ); + } + } + + @Nested + @DisplayName("valueOf()") + class ValueOf { + + @Test + @DisplayName("resolves RENAME") + void resolvesRename() { + assertThat(FieldOperationType.valueOf("RENAME")).isEqualTo(FieldOperationType.RENAME); + } + + @Test + @DisplayName("resolves REMOVE") + void resolvesRemove() { + assertThat(FieldOperationType.valueOf("REMOVE")).isEqualTo(FieldOperationType.REMOVE); + } + + @Test + @DisplayName("resolves ADD") + void resolvesAdd() { + assertThat(FieldOperationType.valueOf("ADD")).isEqualTo(FieldOperationType.ADD); + } + + @Test + @DisplayName("resolves TRANSFORM") + void resolvesTransform() { + assertThat(FieldOperationType.valueOf("TRANSFORM")).isEqualTo(FieldOperationType.TRANSFORM); + } + + @Test + @DisplayName("resolves SET") + void resolvesSet() { + assertThat(FieldOperationType.valueOf("SET")).isEqualTo(FieldOperationType.SET); + } + + @Test + @DisplayName("resolves MOVE") + void resolvesMove() { + assertThat(FieldOperationType.valueOf("MOVE")).isEqualTo(FieldOperationType.MOVE); + } + + @Test + @DisplayName("resolves COPY") + void resolvesCopy() { + assertThat(FieldOperationType.valueOf("COPY")).isEqualTo(FieldOperationType.COPY); + } + + @Test + @DisplayName("resolves GROUP") + void resolvesGroup() { + assertThat(FieldOperationType.valueOf("GROUP")).isEqualTo(FieldOperationType.GROUP); + } + + @Test + @DisplayName("resolves FLATTEN") + void resolvesFlatten() { + assertThat(FieldOperationType.valueOf("FLATTEN")).isEqualTo(FieldOperationType.FLATTEN); + } + + @Test + @DisplayName("resolves CONDITIONAL") + void resolvesConditional() { + assertThat(FieldOperationType.valueOf("CONDITIONAL")).isEqualTo(FieldOperationType.CONDITIONAL); + } + } +} From 8603d0fbd24db60326ab7fe674b2aa24be28e7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?= Date: Mon, 6 Apr 2026 23:12:49 +0200 Subject: [PATCH 02/11] Add field-level diagnostics documentation and examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document field-level diagnostics for rewrite rules, provide usage examples, and update API references across related pages. Signed-off-by: Erik Pförtner --- docs/how-to/field-level-diagnostics.md | 337 +++++++++++++++++++++++++ docs/how-to/index.md | 13 +- docs/how-to/use-diagnostics.md | 168 ++++++++---- 3 files changed, 466 insertions(+), 52 deletions(-) create mode 100644 docs/how-to/field-level-diagnostics.md diff --git a/docs/how-to/field-level-diagnostics.md b/docs/how-to/field-level-diagnostics.md new file mode 100644 index 0000000..ae73347 --- /dev/null +++ b/docs/how-to/field-level-diagnostics.md @@ -0,0 +1,337 @@ +# How to Use Field-Level Diagnostics + +This guide shows how to capture and inspect field-level diagnostic metadata during data migrations, giving you fine-grained visibility into which fields each rule affects. + +## Overview + +Standard type-level diagnostics tell you that a rule matched on a particular type, but not which fields were renamed, removed, or transformed. Field-level diagnostics solve this by capturing structured `FieldOperation` metadata for every field-aware rule application. + +Key points: + +- Field-level diagnostics are **opt-in** via `DiagnosticOptions.captureFieldDetails(true)` (enabled by default in `defaults()`) +- Rules created by the field operation methods in `Rules` automatically implement `FieldAwareRule` +- After migration, inspect `RuleApplication.fieldOperations()` for per-field metadata + +## Quick Start + +```java +import de.splatgames.aether.datafixers.api.diagnostic.*; +import de.splatgames.aether.datafixers.api.rewrite.Rules; + +// 1. Create rules using Rules factory methods +TypeRewriteRule rule = Rules.renameField(ops, "playerName", "name"); + +// 2. The rule automatically implements FieldAwareRule — no extra work needed + +// 3. Run migration with a DiagnosticContext +DiagnosticContext context = DiagnosticContext.create(); +Dynamic result = fixer.update( + TypeReferences.PLAYER, inputData, + new DataVersion(1), new DataVersion(2), + context +); + +// 4. Inspect field operations in the report +MigrationReport report = context.getReport(); +for (FixExecution fix : report.fixExecutions()) { + for (RuleApplication rule : fix.ruleApplications()) { + for (FieldOperation op : rule.fieldOperations()) { + System.out.println(op.toSummary()); + // Output: RENAME(playerName -> name) + } + } +} +``` + +## Which Rules Are Field-Aware? + +All field operation methods in `Rules` produce rules that implement `FieldAwareRule`: + +**Single-field operations:** +- `renameField`, `removeField`, `addField`, `transformField`, `setField` + +**Batch operations:** +- `renameFields`, `removeFields` + +**Structural operations:** +- `groupFields`, `flattenField`, `moveField`, `copyField` + +**Path-based operations (nested fields):** +- `renameFieldAt`, `removeFieldAt`, `addFieldAt`, `transformFieldAt` + +**Conditional operations:** +- `ifFieldExists`, `ifFieldMissing`, `ifFieldEquals` + +**Batch transform:** +- `batch()` via `BatchTransform` + +**Composition methods** (`seq`, `seqAll`, `choice`) aggregate field operations from their children. If all children are field-aware, the composed rule is also a `FieldAwareRule`. + +**Non-field-aware** (traversal/structural): `all`, `one`, `everywhere`, `bottomUp`, `topDown`, `dynamicTransform` — these do not carry field-level metadata. + +## The FieldOperation Record + +`FieldOperation` is a record with four components: + +| Component | Type | Description | +|-------------------|------------------------|----------------------------------------------------------| +| `operationType` | `FieldOperationType` | The kind of field operation (RENAME, REMOVE, etc.) | +| `fieldPath` | `List` | Path segments to the affected field | +| `targetFieldName` | `String` (nullable) | Target field name for operations that have one | +| `description` | `String` (nullable) | Optional human-readable description | + +### Factory Methods + +```java +FieldOperation.rename("old", "new") // RENAME, ["old"], target="new" +FieldOperation.remove("field") // REMOVE, ["field"] +FieldOperation.add("field") // ADD, ["field"] +FieldOperation.transform("field") // TRANSFORM, ["field"] +FieldOperation.set("field") // SET, ["field"] +FieldOperation.move("a.b", "c.d") // MOVE, ["a","b"], target="c.d" +FieldOperation.copy("a.b", "c.d") // COPY, ["a","b"], target="c.d" +FieldOperation.renamePath("pos.x", "posX") // RENAME, ["pos","x"], target="posX" +FieldOperation.group("pos", "x", "y", "z") // GROUP, ["x","y","z"], target="pos" +FieldOperation.flatten("position") // FLATTEN, ["position"] +FieldOperation.conditional("field", "exists") // CONDITIONAL, ["field"], desc="exists" +``` + +### Convenience Methods + +| Method | Return Type | Description | +|------------------------|---------------------|-------------------------------------------------------| +| `fieldPathString()` | `String` | Dot-notation path (e.g., `"position.x"`) | +| `isNested()` | `boolean` | `true` if field path has more than one segment | +| `toSummary()` | `String` | Human-readable summary (e.g., `"RENAME(old -> new)"`) | +| `targetFieldNameOpt()` | `Optional` | Target field name as Optional | +| `descriptionOpt()` | `Optional` | Description as Optional | + +## The FieldOperationType Enum + +Ten operation types classify what a rule does to a field: + +| Constant | Display Name | Requires Target | Structural | +|-----------------|-----------------|-----------------|------------| +| `RENAME` | `"rename"` | Yes | No | +| `REMOVE` | `"remove"` | No | No | +| `ADD` | `"add"` | No | No | +| `TRANSFORM` | `"transform"` | No | No | +| `SET` | `"set"` | No | No | +| `MOVE` | `"move"` | Yes | Yes | +| `COPY` | `"copy"` | Yes | Yes | +| `GROUP` | `"group"` | Yes | Yes | +| `FLATTEN` | `"flatten"` | No | Yes | +| `CONDITIONAL` | `"conditional"` | No | No | + +### Methods + +| Method | Return Type | Description | +|--------------------|-------------|-----------------------------------------------| +| `displayName()` | `String` | Lowercase name (`"rename"`, `"remove"`, etc.) | +| `requiresTarget()` | `boolean` | `true` for RENAME, MOVE, COPY, GROUP | +| `isStructural()` | `boolean` | `true` for MOVE, COPY, GROUP, FLATTEN | +| `toString()` | `String` | Returns `displayName()` | + +## The FieldAwareRule Interface + +Any rule can be checked for field-level metadata at runtime: + +```java +if (rule instanceof FieldAwareRule fieldAware) { + List ops = fieldAware.fieldOperations(); + // inspect field operations +} +``` + +Custom rules can implement `FieldAwareRule` to participate in field-level diagnostics: + +```java +public class MyCustomRule implements TypeRewriteRule, FieldAwareRule { + @Override + public Optional> rewrite(Type type, Typed input) { + // rule logic + } + + @Override + public List fieldOperations() { + return List.of(FieldOperation.transform("myField")); + } +} +``` + +## Composition Aggregation + +Composition methods aggregate field operations from their children: + +```java +TypeRewriteRule composed = Rules.seq( + Rules.renameField(ops, "old", "new"), // 1 RENAME + Rules.addField(ops, "score", defaultVal) // 1 ADD +); + +// composed instanceof FieldAwareRule → true +// fieldOperations() contains 2 entries: RENAME + ADD +``` + +The same aggregation applies to `seqAll()`, `choice()`, and `batch()`. + +If **all** children are non-field-aware (e.g., all are `dynamicTransform` rules), the composition is **not** a `FieldAwareRule`. + +## Aggregation in Reports + +### Per-Rule + +```java +for (RuleApplication rule : fix.ruleApplications()) { + if (rule.hasFieldOperations()) { + for (FieldOperation op : rule.fieldOperations()) { + System.out.println(op.toSummary()); + } + } +} +``` + +### Per-Fix + +```java +List allOps = fix.allFieldOperations(); +int count = fix.fieldOperationCount(); +``` + +### Per-Migration + +```java +int totalOps = report.totalFieldOperationCount(); +``` + +### Filtering by Type + +```java +List renames = rule.fieldOperationsOfType(FieldOperationType.RENAME); +``` + +## Complete Example + +```java +import de.splatgames.aether.datafixers.api.diagnostic.*; +import de.splatgames.aether.datafixers.api.rewrite.*; +import de.splatgames.aether.datafixers.core.fix.SchemaDataFix; + +public class PlayerV1ToV2Fix extends SchemaDataFix { + + public PlayerV1ToV2Fix(Schema inputSchema, Schema outputSchema) { + super("PlayerV1ToV2", inputSchema, outputSchema); + } + + @Override + protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) { + return Rules.seq( + Rules.renameField(ops(), "playerName", "name"), + Rules.removeField(ops(), "legacyId"), + Rules.addField(ops(), "score", ops().createInt(0)), + Rules.transformField(ops(), "health", value -> + ops().createFloat(Math.max(0f, ops().getFloat(value)))) + ); + } +} + +// --- Using the fix with diagnostics --- + +DiagnosticContext context = DiagnosticContext.create( + DiagnosticOptions.builder() + .captureRuleDetails(true) + .captureFieldDetails(true) + .build() +); + +Dynamic result = fixer.update( + TypeReferences.PLAYER, inputData, + new DataVersion(1), new DataVersion(2), + context +); + +MigrationReport report = context.getReport(); + +for (FixExecution fix : report.fixExecutions()) { + System.out.println(fix.fixName() + ": " + fix.fieldOperationCount() + " field ops"); + + for (RuleApplication rule : fix.ruleApplications()) { + if (rule.hasFieldOperations()) { + for (FieldOperation op : rule.fieldOperations()) { + System.out.println(" " + op.toSummary()); + } + } + } +} + +// Output: +// PlayerV1ToV2: 4 field ops +// RENAME(playerName -> name) +// REMOVE(legacyId) +// ADD(score) +// TRANSFORM(health) + +System.out.println("Total field operations: " + report.totalFieldOperationCount()); +``` + +## Performance Considerations + +- **Field metadata is collected at rule construction time** — there is zero runtime cost for the metadata itself +- `captureFieldDetails(false)` skips the `instanceof` check and list copy in `DiagnosticRuleWrapper` +- `captureRuleDetails` must also be `true` for field details to have any effect +- For production, consider `DiagnosticOptions.minimal()` which disables both rule details and field details + +```java +// Development/debugging — full field-level diagnostics +DiagnosticContext devContext = DiagnosticContext.create(DiagnosticOptions.defaults()); + +// Production — timing only, no field details +DiagnosticContext prodContext = DiagnosticContext.create(DiagnosticOptions.minimal()); + +// Best: No context for maximum performance +fixer.update(type, data, from, to); // No context = no overhead +``` + +## API Reference + +### FieldOperation + +| Method / Component | Type | Description | +|------------------------|-------------------------|-------------------------------------------------| +| `operationType()` | `FieldOperationType` | The kind of field operation | +| `fieldPath()` | `List` | Path segments to the affected field | +| `targetFieldName()` | `String` (nullable) | Target field name (rename target, destination) | +| `description()` | `String` (nullable) | Optional human-readable description | +| `fieldPathString()` | `String` | Dot-notation path string | +| `isNested()` | `boolean` | Whether path has more than one segment | +| `targetFieldNameOpt()` | `Optional` | Target as Optional | +| `descriptionOpt()` | `Optional` | Description as Optional | +| `toSummary()` | `String` | Human-readable summary | + +### FieldOperationType + +| Constant | `displayName()` | `requiresTarget()` | `isStructural()` | +|----------------|------------------|----------------------|--------------------| +| `RENAME` | `"rename"` | `true` | `false` | +| `REMOVE` | `"remove"` | `false` | `false` | +| `ADD` | `"add"` | `false` | `false` | +| `TRANSFORM` | `"transform"` | `false` | `false` | +| `SET` | `"set"` | `false` | `false` | +| `MOVE` | `"move"` | `true` | `true` | +| `COPY` | `"copy"` | `true` | `true` | +| `GROUP` | `"group"` | `true` | `true` | +| `FLATTEN` | `"flatten"` | `false` | `true` | +| `CONDITIONAL` | `"conditional"` | `false` | `false` | + +### FieldAwareRule + +| Method | Return Type | Description | +|---------------------|------------------------|---------------------------------| +| `fieldOperations()` | `List` | Field-level operations metadata | + +## Related + +- [Use Diagnostics](use-diagnostics.md) +- [Compose Fixes](compose-fixes.md) +- [Batch Operations](batch-operations.md) +- [Conditional Rules](conditional-rules.md) diff --git a/docs/how-to/index.md b/docs/how-to/index.md index 3323502..eb3f2f8 100644 --- a/docs/how-to/index.md +++ b/docs/how-to/index.md @@ -32,12 +32,13 @@ This section contains task-oriented guides that show you how to accomplish speci ## Development & Testing -| Guide | Description | -|-----------------------------------------|-----------------------------------------| -| [Debug Migrations](debug-migrations.md) | Troubleshoot migration issues | -| [Test Migrations](test-migrations.md) | Write unit tests for your fixes | -| [Log Migrations](log-migrations.md) | Add logging to track migration progress | -| [Use Diagnostics](use-diagnostics.md) | Capture structured migration reports | +| Guide | Description | +|-------------------------------------------------------|------------------------------------------| +| [Debug Migrations](debug-migrations.md) | Troubleshoot migration issues | +| [Test Migrations](test-migrations.md) | Write unit tests for your fixes | +| [Log Migrations](log-migrations.md) | Add logging to track migration progress | +| [Use Diagnostics](use-diagnostics.md) | Capture structured migration reports | +| [Field-Level Diagnostics](field-level-diagnostics.md) | Inspect which fields each rule affects | ## Advanced Usage diff --git a/docs/how-to/use-diagnostics.md b/docs/how-to/use-diagnostics.md index c9a0384..47b7508 100644 --- a/docs/how-to/use-diagnostics.md +++ b/docs/how-to/use-diagnostics.md @@ -80,6 +80,7 @@ Configure what diagnostics to capture: DiagnosticOptions options = DiagnosticOptions.builder() .captureSnapshots(true) // Capture before/after data snapshots .captureRuleDetails(true) // Capture individual rule applications + .captureFieldDetails(true) // Capture field-level operation metadata .maxSnapshotLength(10000) // Truncate snapshots longer than this .prettyPrintSnapshots(true) // Pretty-print JSON snapshots .build(); @@ -91,14 +92,14 @@ DiagnosticOptions options = DiagnosticOptions.builder() // Full diagnostics (default) DiagnosticOptions.defaults(); -// Minimal overhead (timing only, no snapshots) +// Minimal overhead (timing only, no snapshots or field details) DiagnosticOptions.minimal(); ``` -| Preset | Snapshots | Rule Details | Pretty Print | -|--------------|-----------|--------------|--------------| -| `defaults()` | Yes | Yes | Yes | -| `minimal()` | No | No | No | +| Preset | Snapshots | Rule Details | Field Details | Pretty Print | +|--------------|-----------|--------------|---------------|--------------| +| `defaults()` | Yes | Yes | Yes | Yes | +| `minimal()` | No | No | No | No | ## Working with Fix Executions @@ -138,6 +139,41 @@ for (FixExecution fix : report.fixExecutions()) { } ``` +## Field-Level Diagnostics + +Beyond knowing which rules matched, you can inspect exactly which fields each rule affects. Rules created via `Rules.renameField()`, `Rules.removeField()`, and other field operation methods automatically carry structured metadata about their field operations. + +For a comprehensive guide, see [Field-Level Diagnostics](field-level-diagnostics.md). + +### Inspecting Field Operations + +```java +for (FixExecution fix : report.fixExecutions()) { + for (RuleApplication rule : fix.ruleApplications()) { + if (rule.hasFieldOperations()) { + for (FieldOperation op : rule.fieldOperations()) { + System.out.println(op.operationType().displayName() + + " on " + op.fieldPathString()); + } + } + } +} +``` + +### Aggregation + +```java +// All field operations across an entire fix +List allOps = fix.allFieldOperations(); +int fieldCount = fix.fieldOperationCount(); + +// Total across the entire migration +int totalFieldOps = report.totalFieldOperationCount(); + +// Filter by type +List renames = rule.fieldOperationsOfType(FieldOperationType.RENAME); +``` + ## Emitting Warnings from Fixes Your DataFix implementations can emit warnings via the context: @@ -297,56 +333,96 @@ fixer.update(type, data, from, to); // No context = no overhead ### MigrationReport -| Method | Description | -|--------------------------|-------------------------------| -| `type()` | The migrated TypeReference | -| `fromVersion()` | Source version | -| `toVersion()` | Target version | -| `startTime()` | When migration started | -| `totalDuration()` | Total migration time | -| `fixCount()` | Number of fixes applied | -| `fixExecutions()` | List of FixExecution records | -| `ruleApplicationCount()` | Total rule applications | -| `touchedTypes()` | Set of touched TypeReferences | -| `inputSnapshot()` | Optional input snapshot | -| `outputSnapshot()` | Optional output snapshot | -| `hasWarnings()` | Whether warnings were emitted | -| `warnings()` | List of warning messages | -| `toSummary()` | Human-readable summary string | +| Method | Description | +|------------------------------|-----------------------------------------| +| `type()` | The migrated TypeReference | +| `fromVersion()` | Source version | +| `toVersion()` | Target version | +| `startTime()` | When migration started | +| `totalDuration()` | Total migration time | +| `fixCount()` | Number of fixes applied | +| `fixExecutions()` | List of FixExecution records | +| `ruleApplicationCount()` | Total rule applications | +| `totalFieldOperationCount()` | Total field operations across all fixes | +| `touchedTypes()` | Set of touched TypeReferences | +| `inputSnapshot()` | Optional input snapshot | +| `outputSnapshot()` | Optional output snapshot | +| `hasWarnings()` | Whether warnings were emitted | +| `warnings()` | List of warning messages | +| `toSummary()` | Human-readable summary string | ### FixExecution -| Method | Description | -|-----------------------|---------------------------------| -| `fixName()` | Name of the fix | -| `fromVersion()` | Fix input version | -| `toVersion()` | Fix output version | -| `startTime()` | When fix started | -| `duration()` | Fix execution time | -| `durationMillis()` | Duration in milliseconds | -| `ruleApplications()` | List of RuleApplication records | -| `ruleCount()` | Number of rules | -| `matchedRuleCount()` | Number of matched rules | -| `beforeSnapshotOpt()` | Optional before snapshot | -| `afterSnapshotOpt()` | Optional after snapshot | -| `toSummary()` | Human-readable summary | +| Method | Description | +|-------------------------|-----------------------------------| +| `fixName()` | Name of the fix | +| `fromVersion()` | Fix input version | +| `toVersion()` | Fix output version | +| `startTime()` | When fix started | +| `duration()` | Fix execution time | +| `durationMillis()` | Duration in milliseconds | +| `ruleApplications()` | List of RuleApplication records | +| `ruleCount()` | Number of rules | +| `matchedRuleCount()` | Number of matched rules | +| `allFieldOperations()` | All field operations across rules | +| `fieldOperationCount()` | Total field operation count | +| `beforeSnapshotOpt()` | Optional before snapshot | +| `afterSnapshotOpt()` | Optional after snapshot | +| `toSummary()` | Human-readable summary | ### RuleApplication -| Method | Description | -|--------------------|--------------------------| -| `ruleName()` | Name of the rule | -| `typeName()` | TypeReference name | -| `timestamp()` | When rule was applied | -| `duration()` | Rule execution time | -| `durationMillis()` | Duration in milliseconds | -| `matched()` | Whether rule matched | -| `description()` | Optional description | -| `descriptionOpt()` | Description as Optional | -| `toSummary()` | Human-readable summary | +| Method | Description | +|---------------------------|----------------------------------------| +| `ruleName()` | Name of the rule | +| `typeName()` | TypeReference name | +| `timestamp()` | When rule was applied | +| `duration()` | Rule execution time | +| `durationMillis()` | Duration in milliseconds | +| `matched()` | Whether rule matched | +| `description()` | Optional description | +| `descriptionOpt()` | Description as Optional | +| `fieldOperations()` | List of field-level operations | +| `hasFieldOperations()` | Whether field metadata is present | +| `fieldOperationsOfType()` | Filter field ops by FieldOperationType | +| `toSummary()` | Human-readable summary | + +### FieldOperation + +| Method | Description | +|----------------------|------------------------------------------| +| `operationType()` | The `FieldOperationType` enum value | +| `fieldPath()` | Path segments as `List` | +| `targetFieldName()` | Target for rename/move/copy (nullable) | +| `description()` | Optional description (nullable) | +| `fieldPathString()` | Dot-notation path (`"position.x"`) | +| `isNested()` | Whether path has multiple segments | +| `toSummary()` | Human-readable summary | + +### FieldOperationType + +| Constant | Display Name | Requires Target | Structural | +|----------------|-----------------|------------------|------------| +| `RENAME` | `"rename"` | Yes | No | +| `REMOVE` | `"remove"` | No | No | +| `ADD` | `"add"` | No | No | +| `TRANSFORM` | `"transform"` | No | No | +| `SET` | `"set"` | No | No | +| `MOVE` | `"move"` | Yes | Yes | +| `COPY` | `"copy"` | Yes | Yes | +| `GROUP` | `"group"` | Yes | Yes | +| `FLATTEN` | `"flatten"` | No | Yes | +| `CONDITIONAL` | `"conditional"` | No | No | + +### FieldAwareRule + +| Method | Description | +|---------------------|-------------------------------------------------| +| `fieldOperations()` | Returns `List` for this rule | ## Related +- [Field-Level Diagnostics](field-level-diagnostics.md) - [Debug Migrations](debug-migrations.md) - [Log Migrations](log-migrations.md) - [Test Migrations](test-migrations.md) From 558d69e60bbd8bc96f400aa055f5b3858d58e058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?= Date: Tue, 7 Apr 2026 09:16:24 +0200 Subject: [PATCH 03/11] Clarify Javadoc for `setAtPathRecursive`, adding parameter descriptions and improved method details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Erik Pförtner --- .../splatgames/aether/datafixers/api/rewrite/Rules.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java index 3e10f7b..2c36de0 100644 --- a/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java +++ b/aether-datafixers-api/src/main/java/de/splatgames/aether/datafixers/api/rewrite/Rules.java @@ -2552,7 +2552,13 @@ private static String[] splitPath(@NotNull final String path) { } /** - * Recursively sets a value at a path, creating intermediate objects. + * Recursive helper for setAtPath that navigates the path segments and sets the value at the end. + * + * @param dynamic the current dynamic object, must not be {@code null} + * @param parts the path segments, must not be {@code null} + * @param index the current index in the path segments + * @param value the value to set at the target path, must not be {@code null} + * @return the updated dynamic with the value set at the target path */ @NotNull private static Dynamic setAtPathRecursive(@NotNull final Dynamic dynamic, From 2c9a2b7a055af0958f5ee242acfab7be1f693315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?= Date: Tue, 7 Apr 2026 09:38:17 +0200 Subject: [PATCH 04/11] Refactor schema and migration setup for v1.0.0 and v1.1.0 - Split `Schema100` and `Schema110` logic for improved modularity. - Introduce dedicated methods for type templates. - Update migration logic for player data, including field renaming and new structure setup. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Erik Pförtner --- docs/getting-started/your-first-migration.md | 106 ++++++++++--------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/docs/getting-started/your-first-migration.md b/docs/getting-started/your-first-migration.md index ff74686..9063bc6 100644 --- a/docs/getting-started/your-first-migration.md +++ b/docs/getting-started/your-first-migration.md @@ -69,9 +69,10 @@ Define the data structure at version 100: ```java package com.example.game.schema; -import de.splatgames.aether.datafixers.api.DataVersion; import de.splatgames.aether.datafixers.api.dsl.DSL; import de.splatgames.aether.datafixers.api.schema.Schema; +import de.splatgames.aether.datafixers.api.type.TypeRegistry; +import de.splatgames.aether.datafixers.api.type.template.TypeTemplate; import de.splatgames.aether.datafixers.core.type.SimpleTypeRegistry; import com.example.game.TypeReferences; @@ -87,12 +88,22 @@ import com.example.game.TypeReferences; public class Schema100 extends Schema { public Schema100() { - super(new DataVersion(100), null, SimpleTypeRegistry::new); + super(100, null); // No parent - this is the first version + } + + @Override + protected TypeRegistry createTypeRegistry() { + return new SimpleTypeRegistry(); } @Override protected void registerTypes() { - registerType(TypeReferences.PLAYER, DSL.and( + registerType(TypeReferences.PLAYER, player()); + } + + /** Player type template for v1.0.0 */ + public static TypeTemplate player() { + return DSL.and( DSL.field("playerName", DSL.string()), DSL.field("xp", DSL.intType()), DSL.field("x", DSL.doubleType()), @@ -100,7 +111,7 @@ public class Schema100 extends Schema { DSL.field("z", DSL.doubleType()), DSL.field("gameMode", DSL.intType()), DSL.remainder() - )); + ); } } ``` @@ -114,9 +125,10 @@ Define the updated structure: ```java package com.example.game.schema; -import de.splatgames.aether.datafixers.api.DataVersion; import de.splatgames.aether.datafixers.api.dsl.DSL; import de.splatgames.aether.datafixers.api.schema.Schema; +import de.splatgames.aether.datafixers.api.type.TypeRegistry; +import de.splatgames.aether.datafixers.api.type.template.TypeTemplate; import de.splatgames.aether.datafixers.core.type.SimpleTypeRegistry; import com.example.game.TypeReferences; @@ -131,23 +143,33 @@ import com.example.game.TypeReferences; */ public class Schema110 extends Schema { - public Schema110(Schema parent) { - super(new DataVersion(110), parent, SimpleTypeRegistry::new); + public Schema110() { + super(110, new Schema100()); // Extends from v1.0.0 + } + + @Override + protected TypeRegistry createTypeRegistry() { + return new SimpleTypeRegistry(); } @Override protected void registerTypes() { - registerType(TypeReferences.PLAYER, DSL.and( + registerType(TypeReferences.PLAYER, player()); + } + + /** Player type template for v1.1.0 */ + public static TypeTemplate player() { + return DSL.and( DSL.field("name", DSL.string()), DSL.field("experience", DSL.intType()), DSL.field("position", position()), DSL.field("gameMode", DSL.string()), DSL.remainder() - )); + ); } /** Position type template */ - public static DSL.TypeTemplate position() { + public static TypeTemplate position() { return DSL.and( DSL.field("x", DSL.doubleType()), DSL.field("y", DSL.doubleType()), @@ -172,17 +194,18 @@ import de.splatgames.aether.datafixers.api.rewrite.Rules; import de.splatgames.aether.datafixers.api.rewrite.TypeRewriteRule; import de.splatgames.aether.datafixers.api.schema.Schema; import de.splatgames.aether.datafixers.api.schema.SchemaRegistry; +import de.splatgames.aether.datafixers.codec.json.gson.GsonOps; import de.splatgames.aether.datafixers.core.fix.SchemaDataFix; -import com.example.game.TypeReferences; +import org.jetbrains.annotations.NotNull; /** * Migrates player data from v1.0.0 (100) to v1.1.0 (110). */ -public class PlayerV1ToV2Fix extends SchemaDataFix { +public class PlayerV100ToV110Fix extends SchemaDataFix { - public PlayerV1ToV2Fix(SchemaRegistry schemas) { + public PlayerV100ToV110Fix(SchemaRegistry schemas) { super( - "player_v1_to_v2", + "player_v100_to_v110", new DataVersion(100), new DataVersion(110), schemas @@ -190,22 +213,26 @@ public class PlayerV1ToV2Fix extends SchemaDataFix { } @Override - protected TypeRewriteRule makeRule(Schema inputSchema, Schema outputSchema) { + @NotNull + protected TypeRewriteRule makeRule(@NotNull Schema inputSchema, + @NotNull Schema outputSchema) { return Rules.seq( // 1. Rename fields - Rules.renameField(TypeReferences.PLAYER, "playerName", "name"), - Rules.renameField(TypeReferences.PLAYER, "xp", "experience"), + Rules.renameField(GsonOps.INSTANCE, "playerName", "name"), + Rules.renameField(GsonOps.INSTANCE, "xp", "experience"), // 2. Transform gameMode from int to string - Rules.transformField(TypeReferences.PLAYER, "gameMode", this::gameModeToString), + Rules.transformField(GsonOps.INSTANCE, "gameMode", + PlayerV100ToV110Fix::gameModeToString), // 3. Group coordinates into position object - Rules.transform(TypeReferences.PLAYER, this::groupPosition) + Rules.groupFields(GsonOps.INSTANCE, "position", "x", "y", "z") ); } - private Dynamic gameModeToString(Dynamic value) { - int mode = value.asInt().orElse(0); + @NotNull + private static Dynamic gameModeToString(@NotNull Dynamic value) { + int mode = value.asInt().result().orElse(0); String modeName = switch (mode) { case 0 -> "survival"; case 1 -> "creative"; @@ -215,26 +242,6 @@ public class PlayerV1ToV2Fix extends SchemaDataFix { }; return value.createString(modeName); } - - private Dynamic groupPosition(Dynamic player) { - // Extract coordinates - double x = player.get("x").asDouble().orElse(0.0); - double y = player.get("y").asDouble().orElse(0.0); - double z = player.get("z").asDouble().orElse(0.0); - - // Create position object - Dynamic position = player.emptyMap() - .set("x", player.createDouble(x)) - .set("y", player.createDouble(y)) - .set("z", player.createDouble(z)); - - // Remove old fields and add position - return player - .remove("x") - .remove("y") - .remove("z") - .set("position", position); - } } ``` @@ -254,6 +261,7 @@ import de.splatgames.aether.datafixers.api.schema.SchemaRegistry; import com.example.game.fix.PlayerV1ToV2Fix; import com.example.game.schema.Schema100; import com.example.game.schema.Schema110; +import org.jetbrains.annotations.NotNull; /** * Bootstrap for the game data fixer. @@ -266,21 +274,18 @@ public class GameDataBootstrap implements DataFixerBootstrap { private SchemaRegistry schemas; @Override - public void registerSchemas(SchemaRegistry schemas) { + public void registerSchemas(@NotNull SchemaRegistry schemas) { this.schemas = schemas; // Register schemas in version order - Schema100 v100 = new Schema100(); - Schema110 v110 = new Schema110(v100); - - schemas.register(v100); - schemas.register(v110); + schemas.register(new Schema100()); + schemas.register(new Schema110()); } @Override - public void registerFixes(FixRegistrar fixes) { + public void registerFixes(@NotNull FixRegistrar fixes) { // Register fixes - fixes.register(TypeReferences.PLAYER, new PlayerV1ToV2Fix(schemas)); + fixes.register(TypeReferences.PLAYER, new PlayerV100ToV110Fix(schemas)); } } ``` @@ -337,7 +342,6 @@ public class GameExample { // 5. Print result System.out.println("\n=== Migrated Data (v1.1.0) ==="); - @SuppressWarnings("unchecked") Dynamic result = (Dynamic) migrated.value(); System.out.println(GSON.toJson(result.value())); } @@ -398,7 +402,7 @@ Define all type references in one class for easy discovery. ### 4. Use Parent Schemas -Chain schemas: `Schema110(v100)` inherits from `Schema100`. +Each schema creates its own parent internally: `Schema110` extends `Schema100` via `super(110, new Schema100())`. ### 5. Test Your Fixes From 0e62942d7547aabeb7dc3f253b824f2103429cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?= Date: Thu, 9 Apr 2026 23:32:19 +0200 Subject: [PATCH 05/11] Enable field-level diagnostics in migration CLI and expose in reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for detailed field-level diagnostics during migrations. Extend CLI command options, enhance diagnostic report generation (text/JSON formats), and integrate diagnostics into the actuator endpoint. Signed-off-by: Erik Pförtner --- .../cli/command/MigrateCommand.java | 94 +++++- .../cli/report/JsonReportFormatter.java | 114 +++++++ .../cli/report/ReportFormatter.java | 20 ++ .../cli/report/TextReportFormatter.java | 87 +++++ .../cli/report/DiagnosticFormatterTest.java | 310 ++++++++++++++++++ .../datafixers/core/AetherDataFixer.java | 42 +++ .../spring/AetherDataFixersProperties.java | 36 ++ .../spring/actuator/DataFixerEndpoint.java | 158 ++++++++- .../ActuatorAutoConfiguration.java | 18 +- .../service/DefaultMigrationService.java | 68 +++- .../spring/service/DiagnosticReportStore.java | 96 ++++++ .../spring/service/MigrationResult.java | 95 +++++- .../spring/service/MigrationService.java | 46 +++ .../actuator/DataFixerEndpointTest.java | 117 ++++++- .../service/DiagnosticReportStoreTest.java | 105 ++++++ .../spring/service/MigrationResultTest.java | 53 +++ 16 files changed, 1427 insertions(+), 32 deletions(-) create mode 100644 aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/report/DiagnosticFormatterTest.java create mode 100644 aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStore.java create mode 100644 aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStoreTest.java diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java index 8a1e3cd..0d39203 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java @@ -28,6 +28,9 @@ import de.splatgames.aether.datafixers.api.bootstrap.DataFixerBootstrap; import de.splatgames.aether.datafixers.api.dynamic.Dynamic; import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import de.splatgames.aether.datafixers.cli.bootstrap.BootstrapLoader; import de.splatgames.aether.datafixers.cli.format.FormatHandler; import de.splatgames.aether.datafixers.cli.format.FormatRegistry; @@ -36,6 +39,7 @@ import de.splatgames.aether.datafixers.core.AetherDataFixer; import de.splatgames.aether.datafixers.core.bootstrap.DataFixerRuntimeFactory; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import picocli.CommandLine.Parameters; @@ -396,6 +400,32 @@ public class MigrateCommand implements Callable { ) private boolean verbose; + /** + * Whether to enable field-level diagnostics output. + * + *

    When {@code true}, a detailed diagnostic report is generated for each + * migrated file, including information about every fix execution, rule + * application, and field-level operation (renames, removals, additions, + * transforms, etc.).

    + * + *

    Diagnostics output is written alongside the migration report: to + * {@link #reportFile} if specified, or to stderr otherwise. The output + * format follows the selected {@link #reportFormat}.

    + * + *

    Default value: {@code false}

    + * + *

    CLI usage: {@code --diagnostics}

    + * + * @see DiagnosticContext + * @see de.splatgames.aether.datafixers.api.diagnostic.FieldOperation + * @since 1.0.0 + */ + @Option( + names = {"--diagnostics"}, + description = "Enable field-level diagnostics output showing detailed fix and field operation information." + ) + private boolean diagnostics; + /** * Whether to pretty-print the output JSON. * @@ -474,6 +504,8 @@ public Integer call() { int errorCount = 0; final StringBuilder reportBuilder = new StringBuilder(); + final StringBuilder diagnosticBuilder = new StringBuilder(); + for (final File inputFile : this.inputFiles) { try { final MigrationResult result = processFile( @@ -483,6 +515,9 @@ public Integer call() { if (this.generateReport) { reportBuilder.append(result.report).append("\n"); } + if (this.diagnostics && !result.diagnosticOutput.isEmpty()) { + diagnosticBuilder.append(result.diagnosticOutput).append("\n"); + } } catch (final Exception e) { errorCount++; System.err.println("Error processing " + inputFile + ": " + e.getMessage()); @@ -505,6 +540,19 @@ public Integer call() { } } + // Write diagnostic output + if (this.diagnostics && !diagnosticBuilder.isEmpty()) { + final String diagnosticContent = diagnosticBuilder.toString(); + if (this.reportFile != null) { + Files.writeString(this.reportFile.toPath(), diagnosticContent, + StandardCharsets.UTF_8, + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.APPEND); + } else { + System.err.println(diagnosticContent); + } + } + // Summary if (this.inputFiles.size() > 1 || this.verbose) { System.err.println("Completed: " + successCount + " migrated, " + errorCount + " errors"); @@ -590,15 +638,24 @@ private MigrationResult processFile( System.err.println("Skipping " + inputFile + " (already at v" + sourceVersion.getVersion() + ")"); } - return new MigrationResult("", Duration.ZERO); + return new MigrationResult("", Duration.ZERO, ""); } // Create dynamic and migrate final Dynamic dynamic = new Dynamic<>(handler.ops(), data); final TaggedDynamic tagged = new TaggedDynamic(typeRef, dynamic); - // Perform migration - final TaggedDynamic migrated = fixer.update(tagged, sourceVersion, targetVersion); + // Perform migration (with diagnostics if enabled) + final DiagnosticContext diagCtx = this.diagnostics + ? DiagnosticContext.create(DiagnosticOptions.builder() + .captureSnapshots(false) + .captureRuleDetails(true) + .captureFieldDetails(true) + .build()) + : null; + final TaggedDynamic migrated = diagCtx != null + ? fixer.update(tagged, sourceVersion, targetVersion, diagCtx) + : fixer.update(tagged, sourceVersion, targetVersion); // Extract result @SuppressWarnings("unchecked") @@ -631,7 +688,19 @@ private MigrationResult processFile( ); } - return new MigrationResult(report, duration); + // Generate diagnostic report + final MigrationReport diagnosticReport = diagCtx != null ? diagCtx.getReport() : null; + String diagnosticOutput = ""; + if (diagnosticReport != null) { + final ReportFormatter formatter = ReportFormatter.forFormat(this.reportFormat); + diagnosticOutput = formatter.formatDiagnostic( + inputFile.getName(), + typeRef.getId(), + diagnosticReport + ); + } + + return new MigrationResult(report, duration, diagnosticOutput); } /** @@ -719,6 +788,21 @@ private void writeOutput(@NotNull final File inputFile, @NotNull final String co * @see #processFile(File, AetherDataFixer, FormatHandler, TypeReference, DataVersion) * @see ReportFormatter */ - private record MigrationResult(String report, Duration duration) { + /** + * Holds the result of a single file migration operation. + * + *

    This record captures the outcome of migrating one file, including + * the formatted report string (if reporting is enabled), the time + * taken for the migration, and the optional diagnostic output (if + * {@code --diagnostics} was enabled).

    + * + * @param report the formatted migration report string, empty if reporting is disabled + * or if the file was skipped (already at target version) + * @param duration the time elapsed during the migration process, including file I/O + * @param diagnosticOutput the formatted diagnostic report string, empty if diagnostics are disabled + * @see #processFile(File, AetherDataFixer, FormatHandler, TypeReference, DataVersion) + * @see ReportFormatter + */ + private record MigrationResult(String report, Duration duration, String diagnosticOutput) { } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java index 34d57b9..7b2a6dd 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java @@ -25,7 +25,12 @@ import com.google.common.base.Preconditions; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FixExecution; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; import org.jetbrains.annotations.NotNull; import java.time.Duration; @@ -122,4 +127,113 @@ public String formatSimple( return GSON.toJson(json); } + + /** + * Formats a diagnostic migration report as a JSON object. + * + *

    The output is a pretty-printed JSON object with the following structure:

    + *
    {@code
    +     * {
    +     *   "file": "player.json",
    +     *   "type": "player",
    +     *   "fromVersion": 100,
    +     *   "toVersion": 200,
    +     *   "durationMs": 42,
    +     *   "fixCount": 2,
    +     *   "ruleCount": 3,
    +     *   "fieldOperationCount": 5,
    +     *   "fixes": [
    +     *     {
    +     *       "name": "PlayerV1ToV2Fix",
    +     *       "fromVersion": 100,
    +     *       "toVersion": 150,
    +     *       "durationMs": 20,
    +     *       "rules": [
    +     *         {
    +     *           "name": "renameField",
    +     *           "matched": true,
    +     *           "durationMs": 5,
    +     *           "fieldOperations": [
    +     *             {
    +     *               "type": "RENAME",
    +     *               "field": "oldName",
    +     *               "target": "newName"
    +     *             }
    +     *           ]
    +     *         }
    +     *       ]
    +     *     }
    +     *   ],
    +     *   "warnings": []
    +     * }
    +     * }
    + * + * @param fileName the name of the migrated file + * @param type the type reference ID (e.g., "player", "world") + * @param report the diagnostic migration report containing fix and field operation details + * @return a pretty-printed JSON string representing the diagnostic report + */ + @Override + @NotNull + public String formatDiagnostic( + @NotNull final String fileName, + @NotNull final String type, + @NotNull final MigrationReport report + ) { + Preconditions.checkNotNull(fileName, "fileName must not be null"); + Preconditions.checkNotNull(type, "type must not be null"); + Preconditions.checkNotNull(report, "report must not be null"); + + final JsonObject json = new JsonObject(); + json.addProperty("file", fileName); + json.addProperty("type", type); + json.addProperty("fromVersion", report.fromVersion().getVersion()); + json.addProperty("toVersion", report.toVersion().getVersion()); + json.addProperty("durationMs", report.totalDuration().toMillis()); + json.addProperty("fixCount", report.fixCount()); + json.addProperty("ruleCount", report.ruleApplicationCount()); + json.addProperty("fieldOperationCount", report.totalFieldOperationCount()); + + final JsonArray fixes = new JsonArray(); + for (final FixExecution fix : report.fixExecutions()) { + final JsonObject fixObj = new JsonObject(); + fixObj.addProperty("name", fix.fixName()); + fixObj.addProperty("fromVersion", fix.fromVersion().getVersion()); + fixObj.addProperty("toVersion", fix.toVersion().getVersion()); + fixObj.addProperty("durationMs", fix.durationMillis()); + + final JsonArray rules = new JsonArray(); + for (final RuleApplication rule : fix.ruleApplications()) { + final JsonObject ruleObj = new JsonObject(); + ruleObj.addProperty("name", rule.ruleName()); + ruleObj.addProperty("matched", rule.matched()); + ruleObj.addProperty("durationMs", rule.durationMillis()); + + final JsonArray fieldOps = new JsonArray(); + for (final FieldOperation fieldOp : rule.fieldOperations()) { + final JsonObject opObj = new JsonObject(); + opObj.addProperty("type", fieldOp.operationType().name()); + opObj.addProperty("field", fieldOp.fieldPathString()); + fieldOp.targetFieldNameOpt() + .ifPresent(target -> opObj.addProperty("target", target)); + fieldOp.descriptionOpt() + .ifPresent(desc -> opObj.addProperty("description", desc)); + fieldOps.add(opObj); + } + ruleObj.add("fieldOperations", fieldOps); + rules.add(ruleObj); + } + fixObj.add("rules", rules); + fixes.add(fixObj); + } + json.add("fixes", fixes); + + final JsonArray warnings = new JsonArray(); + for (final String warning : report.warnings()) { + warnings.add(warning); + } + json.add("warnings", warnings); + + return GSON.toJson(json); + } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java index a83d2d8..091613a 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java @@ -23,6 +23,7 @@ package de.splatgames.aether.datafixers.cli.report; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import org.jetbrains.annotations.NotNull; import java.time.Duration; @@ -72,6 +73,25 @@ String formatSimple( @NotNull Duration duration ); + /** + * Formats a diagnostic migration report including field-level operation details. + * + *

    When diagnostics are enabled, the report includes comprehensive information + * about each fix execution and its field-level operations (renames, removals, + * additions, transforms, etc.).

    + * + * @param fileName the name of the migrated file + * @param type the type reference ID + * @param report the diagnostic migration report + * @return the formatted diagnostic report string + */ + @NotNull + String formatDiagnostic( + @NotNull String fileName, + @NotNull String type, + @NotNull MigrationReport report + ); + /** * Gets a formatter by format name. * diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java index 54de636..cfd2bed 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java @@ -23,6 +23,10 @@ package de.splatgames.aether.datafixers.cli.report; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FixExecution; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; import org.jetbrains.annotations.NotNull; import java.time.Duration; @@ -85,4 +89,87 @@ public String formatSimple( return "Migration: " + fileName + " [" + type + "] v" + fromVersion + " -> v" + toVersion + " (" + duration.toMillis() + "ms)"; } + + /** + * Formats a diagnostic migration report as human-readable plain text. + * + *

    The output includes a summary line followed by per-fix details with + * field-level operations. Example output:

    + *
    +     * Diagnostic Report: player.json [player] v100 -> v200 (42ms)
    +     *   Fixes applied: 2, Rules: 3, Field operations: 5
    +     *
    +     *   Fix: PlayerV1ToV2Fix (v100 -> v150) 20ms
    +     *     Rule: renameField [matched] 5ms
    +     *       RENAME(oldName -> newName)
    +     *     Rule: addField [matched] 3ms
    +     *       ADD(health)
    +     *
    +     *   Fix: PlayerV2ToV3Fix (v150 -> v200) 22ms
    +     *     Rule: seq [matched] 10ms
    +     *       REMOVE(deprecated)
    +     *       TRANSFORM(stats)
    +     *       SET(version)
    +     * 
    + * + * @param fileName the name of the migrated file + * @param type the type reference ID (e.g., "player", "world") + * @param report the diagnostic migration report containing fix and field operation details + * @return a multi-line formatted diagnostic report string + */ + @Override + @NotNull + public String formatDiagnostic( + @NotNull final String fileName, + @NotNull final String type, + @NotNull final MigrationReport report + ) { + Preconditions.checkNotNull(fileName, "fileName must not be null"); + Preconditions.checkNotNull(type, "type must not be null"); + Preconditions.checkNotNull(report, "report must not be null"); + + final StringBuilder sb = new StringBuilder(); + + // Header + sb.append(String.format("Diagnostic Report: %s [%s] v%d -> v%d (%dms)%n", + fileName, type, + report.fromVersion().getVersion(), + report.toVersion().getVersion(), + report.totalDuration().toMillis())); + + sb.append(String.format(" Fixes applied: %d, Rules: %d, Field operations: %d%n", + report.fixCount(), + report.ruleApplicationCount(), + report.totalFieldOperationCount())); + + // Per-fix details + for (final FixExecution fix : report.fixExecutions()) { + sb.append(String.format("%n Fix: %s (v%d -> v%d) %dms%n", + fix.fixName(), + fix.fromVersion().getVersion(), + fix.toVersion().getVersion(), + fix.durationMillis())); + + for (final RuleApplication rule : fix.ruleApplications()) { + sb.append(String.format(" Rule: %s [%s] %dms%n", + rule.ruleName(), + rule.matched() ? "matched" : "skipped", + rule.durationMillis())); + + for (final FieldOperation fieldOp : rule.fieldOperations()) { + sb.append(" ").append(fieldOp.toSummary()).append('\n'); + } + } + } + + // Warnings + if (report.hasWarnings()) { + sb.append("\n Warnings:\n"); + for (final String warning : report.warnings()) { + sb.append(" ! ").append(warning).append('\n'); + } + } + + return sb.toString(); + } } diff --git a/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/report/DiagnosticFormatterTest.java b/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/report/DiagnosticFormatterTest.java new file mode 100644 index 0000000..9419b02 --- /dev/null +++ b/aether-datafixers-cli/src/test/java/de/splatgames/aether/datafixers/cli/report/DiagnosticFormatterTest.java @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.cli.report; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for the {@code formatDiagnostic} method in {@link TextReportFormatter} + * and {@link JsonReportFormatter}. + * + * @author Erik Pförtner + * @since 1.0.0 + */ +@DisplayName("Diagnostic Report Formatting") +class DiagnosticFormatterTest { + + /** + * Creates a minimal diagnostic report for testing using the DiagnosticContext builder. + * + * @param fieldOps the field operations to include in the rule application + * @return a migration report with one fix and one rule + */ + private static MigrationReport createTestReport(final List fieldOps) { + final DiagnosticContext ctx = DiagnosticContext.create( + DiagnosticOptions.builder() + .captureSnapshots(false) + .captureRuleDetails(true) + .captureFieldDetails(true) + .build() + ); + + final TypeReference type = new TypeReference("player"); + final DataVersion from = new DataVersion(100); + final DataVersion to = new DataVersion(200); + + final MigrationReport.Builder builder = ctx.reportBuilder(); + builder.startMigration(type, from, to); + + // Simulate a fix with a single rule application + final de.splatgames.aether.datafixers.api.fix.DataFix fakeFix = + new FakeDataFix("TestFix", from, to); + + builder.startFix(fakeFix); + builder.recordRuleApplication(new RuleApplication( + "renameField", "player", Instant.now(), + Duration.ofMillis(5), true, null, fieldOps + )); + builder.endFix(fakeFix, Duration.ofMillis(20), null); + + return builder.build(); + } + + /** + * Minimal DataFix implementation for test purposes. + */ + private static final class FakeDataFix implements de.splatgames.aether.datafixers.api.fix.DataFix { + + private final String name; + private final DataVersion fromVersion; + private final DataVersion toVersion; + + FakeDataFix(final String name, final DataVersion from, final DataVersion to) { + this.name = name; + this.fromVersion = from; + this.toVersion = to; + } + + @Override + public String name() { + return this.name; + } + + @Override + public DataVersion fromVersion() { + return this.fromVersion; + } + + @Override + public DataVersion toVersion() { + return this.toVersion; + } + + @Override + public de.splatgames.aether.datafixers.api.dynamic.Dynamic apply( + final de.splatgames.aether.datafixers.api.TypeReference type, + final de.splatgames.aether.datafixers.api.dynamic.Dynamic input, + final de.splatgames.aether.datafixers.api.fix.DataFixerContext context + ) { + return input; + } + } + + @Nested + @DisplayName("TextReportFormatter.formatDiagnostic()") + class TextFormat { + + private final TextReportFormatter formatter = new TextReportFormatter(); + + @Test + @DisplayName("includes diagnostic report header") + void includesHeader() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Diagnostic Report:"); + assertThat(result).contains("player.json"); + assertThat(result).contains("[player]"); + assertThat(result).contains("v100"); + assertThat(result).contains("v200"); + } + + @Test + @DisplayName("includes fix details") + void includesFixDetails() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Fix: TestFix"); + assertThat(result).contains("Rule: renameField"); + assertThat(result).contains("[matched]"); + } + + @Test + @DisplayName("includes field operation summaries") + void includesFieldOperations() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"), + FieldOperation.remove("deprecated"), + FieldOperation.add("health") + )); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("RENAME(oldName -> newName)"); + assertThat(result).contains("REMOVE(deprecated)"); + assertThat(result).contains("ADD(health)"); + } + + @Test + @DisplayName("includes counts summary") + void includesCountsSummary() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("a", "b"), + FieldOperation.remove("c") + )); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Fixes applied: 1"); + assertThat(result).contains("Rules: 1"); + assertThat(result).contains("Field operations: 2"); + } + + @Test + @DisplayName("handles empty field operations") + void handlesEmptyFieldOps() { + final MigrationReport report = createTestReport(List.of()); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + assertThat(result).contains("Field operations: 0"); + } + + @Test + @DisplayName("rejects null arguments") + void rejectsNullArguments() { + final MigrationReport report = createTestReport(List.of()); + + assertThatThrownBy(() -> formatter.formatDiagnostic(null, "player", report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", null, report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", "player", null)) + .isInstanceOf(NullPointerException.class); + } + } + + @Nested + @DisplayName("JsonReportFormatter.formatDiagnostic()") + class JsonFormat { + + private final JsonReportFormatter formatter = new JsonReportFormatter(); + + @Test + @DisplayName("produces valid JSON") + void producesValidJson() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + assertThat(json).isNotNull(); + } + + @Test + @DisplayName("includes all top-level fields") + void includesTopLevelFields() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"))); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + + assertThat(json.get("file").getAsString()).isEqualTo("player.json"); + assertThat(json.get("type").getAsString()).isEqualTo("player"); + assertThat(json.get("fromVersion").getAsInt()).isEqualTo(100); + assertThat(json.get("toVersion").getAsInt()).isEqualTo(200); + assertThat(json.get("fixCount").getAsInt()).isEqualTo(1); + assertThat(json.get("ruleCount").getAsInt()).isEqualTo(1); + assertThat(json.get("fieldOperationCount").getAsInt()).isEqualTo(1); + } + + @Test + @DisplayName("includes field operations in rules") + void includesFieldOperations() { + final MigrationReport report = createTestReport(List.of( + FieldOperation.rename("oldName", "newName"), + FieldOperation.remove("deprecated") + )); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + + final var fixes = json.getAsJsonArray("fixes"); + assertThat(fixes).hasSize(1); + + final var rules = fixes.get(0).getAsJsonObject().getAsJsonArray("rules"); + assertThat(rules).hasSize(1); + + final var fieldOps = rules.get(0).getAsJsonObject().getAsJsonArray("fieldOperations"); + assertThat(fieldOps).hasSize(2); + + final var renameOp = fieldOps.get(0).getAsJsonObject(); + assertThat(renameOp.get("type").getAsString()).isEqualTo("RENAME"); + assertThat(renameOp.get("field").getAsString()).isEqualTo("oldName"); + assertThat(renameOp.get("target").getAsString()).isEqualTo("newName"); + + final var removeOp = fieldOps.get(1).getAsJsonObject(); + assertThat(removeOp.get("type").getAsString()).isEqualTo("REMOVE"); + assertThat(removeOp.get("field").getAsString()).isEqualTo("deprecated"); + } + + @Test + @DisplayName("includes warnings array") + void includesWarnings() { + final MigrationReport report = createTestReport(List.of()); + + final String result = formatter.formatDiagnostic("player.json", "player", report); + final JsonObject json = JsonParser.parseString(result).getAsJsonObject(); + + assertThat(json.getAsJsonArray("warnings")).isNotNull(); + } + + @Test + @DisplayName("rejects null arguments") + void rejectsNullArguments() { + final MigrationReport report = createTestReport(List.of()); + + assertThatThrownBy(() -> formatter.formatDiagnostic(null, "player", report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", null, report)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> formatter.formatDiagnostic("file", "player", null)) + .isInstanceOf(NullPointerException.class); + } + } +} diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java index 32f4c50..996837b 100644 --- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java +++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java @@ -31,6 +31,7 @@ import de.splatgames.aether.datafixers.api.exception.DecodeException; import de.splatgames.aether.datafixers.api.exception.EncodeException; import de.splatgames.aether.datafixers.api.fix.DataFixer; +import de.splatgames.aether.datafixers.api.fix.DataFixerContext; import de.splatgames.aether.datafixers.api.schema.Schema; import de.splatgames.aether.datafixers.api.schema.SchemaRegistry; import de.splatgames.aether.datafixers.api.type.Type; @@ -194,6 +195,47 @@ public TaggedDynamic update( return new TaggedDynamic(input.type(), updated); } + /** + * Updates data from one version to another using the specified context. + * + *

    Applies all registered fixes between the source and target versions + * to migrate the data. The provided {@link DataFixerContext} controls + * logging and diagnostic behavior during migration. Pass a + * {@link de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext} + * to capture detailed migration diagnostics including field-level operations.

    + * + * @param input the tagged dynamic data to update, must not be {@code null} + * @param fromVersion the source version of the data, must not be {@code null} + * @param toVersion the target version to migrate to, must not be {@code null} + * @param context the fixer context for logging and diagnostics, must not be {@code null} + * @return a new tagged dynamic with the updated data + * @throws NullPointerException if any argument is {@code null} + * @since 1.0.0 + */ + @NotNull + public TaggedDynamic update( + @NotNull final TaggedDynamic input, + @NotNull final DataVersion fromVersion, + @NotNull final DataVersion toVersion, + @NotNull final DataFixerContext context + ) { + Preconditions.checkNotNull(input, "input must not be null"); + Preconditions.checkNotNull(fromVersion, "fromVersion must not be null"); + Preconditions.checkNotNull(toVersion, "toVersion must not be null"); + Preconditions.checkNotNull(context, "context must not be null"); + Preconditions.checkArgument( + fromVersion.compareTo(toVersion) <= 0, + "fromVersion (%s) must be <= toVersion (%s)", fromVersion, toVersion + ); + + @SuppressWarnings("unchecked") final Dynamic dyn = (Dynamic) input.value(); + + final Dynamic updated = + this.dataFixer.update(input.type(), dyn, fromVersion, toVersion, context); + + return new TaggedDynamic(input.type(), updated); + } + /** * Decodes a tagged dynamic to a Java object. * diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java index 8c6a797..aac468f 100644 --- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java +++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/AetherDataFixersProperties.java @@ -352,6 +352,18 @@ public static class ActuatorProperties { */ private boolean includeFixDetails = true; + /** + * Flag to include field-level diagnostic details in actuator responses. + * + *

    When enabled and migration diagnostics are captured (via + * {@link de.splatgames.aether.datafixers.spring.service.MigrationService.MigrationRequestBuilder#withDiagnostics()}), + * the last migration's field operation details are exposed through the + * {@code /actuator/datafixers/{domain}} endpoint.

    + * + * @since 1.0.0 + */ + private boolean includeFieldDetails = true; + /** * Returns whether schema details are included in actuator responses. * @@ -395,6 +407,30 @@ public boolean isIncludeFixDetails() { public void setIncludeFixDetails(final boolean includeFixDetails) { this.includeFixDetails = includeFixDetails; } + + /** + * Returns whether field-level diagnostic details are included in actuator responses. + * + *

    Field details include information about which fields are affected by each + * migration rule (renames, removals, additions, transforms, etc.).

    + * + * @return {@code true} if field details are included, {@code false} otherwise + * @since 1.0.0 + */ + public boolean isIncludeFieldDetails() { + return this.includeFieldDetails; + } + + /** + * Sets whether field-level diagnostic details are included in actuator responses. + * + * @param includeFieldDetails {@code true} to include field details, + * {@code false} to exclude them + * @since 1.0.0 + */ + public void setIncludeFieldDetails(final boolean includeFieldDetails) { + this.includeFieldDetails = includeFieldDetails; + } } /** diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java index b232fa5..de610ba 100644 --- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java +++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpoint.java @@ -23,8 +23,13 @@ package de.splatgames.aether.datafixers.spring.actuator; import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FixExecution; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; import de.splatgames.aether.datafixers.core.AetherDataFixer; import de.splatgames.aether.datafixers.spring.autoconfigure.DataFixerRegistry; +import de.splatgames.aether.datafixers.spring.service.DiagnosticReportStore; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; @@ -32,6 +37,7 @@ import org.springframework.boot.actuate.endpoint.annotation.Selector; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -160,18 +166,48 @@ public class DataFixerEndpoint { */ private final DataFixerRegistry registry; + /** + * Optional store for diagnostic reports. May be null if no DefaultMigrationService + * is available. + */ + @Nullable + private final DiagnosticReportStore diagnosticReportStore; + /** * Creates a new DataFixerEndpoint with the specified registry. * *

    The endpoint will expose information about all DataFixers registered - * in the provided registry through its operations.

    + * in the provided registry through its operations. No diagnostic report + * store is attached; field-level diagnostics will not be available.

    * * @param registry the DataFixer registry containing all domain fixers, * must not be {@code null} * @throws NullPointerException if registry is {@code null} */ public DataFixerEndpoint(@NotNull final DataFixerRegistry registry) { + this(registry, null); + } + + /** + * Creates a new DataFixerEndpoint with the specified registry and diagnostic report store. + * + *

    The endpoint will expose information about all DataFixers registered + * in the provided registry. When a diagnostic report store is provided, + * the domain details response will include field-level operation summaries + * from the most recent diagnostic migration.

    + * + * @param registry the DataFixer registry containing all domain fixers, + * must not be {@code null} + * @param diagnosticReportStore the store for diagnostic reports, may be {@code null} + * @throws NullPointerException if registry is {@code null} + * @since 1.0.0 + */ + public DataFixerEndpoint( + @NotNull final DataFixerRegistry registry, + @Nullable final DiagnosticReportStore diagnosticReportStore + ) { this.registry = Preconditions.checkNotNull(registry, "registry must not be null"); + this.diagnosticReportStore = diagnosticReportStore; } /** @@ -251,22 +287,62 @@ public DomainDetails domainDetails(@Selector final String domain) { } try { + // Build field diagnostics summary if available + final FieldDiagnosticsSummary fieldDiagnostics = + this.diagnosticReportStore != null + ? this.diagnosticReportStore.get(domain) + .map(DataFixerEndpoint::buildFieldDiagnosticsSummary) + .orElse(null) + : null; + return new DomainDetails( domain, fixer.currentVersion().getVersion(), "UP", - null + null, + fieldDiagnostics ); } catch (final Exception e) { return new DomainDetails( domain, -1, "DOWN", - e.getMessage() + e.getMessage(), + null ); } } + /** + * Builds a field diagnostics summary from a migration report. + * + * @param report the migration report to summarize + * @return the field diagnostics summary + */ + @NotNull + private static FieldDiagnosticsSummary buildFieldDiagnosticsSummary( + @NotNull final MigrationReport report + ) { + final List operations = report.fixExecutions().stream() + .flatMap(fix -> fix.allFieldOperations().stream() + .map(op -> new FieldOperationSummary( + op.operationType().name(), + op.fieldPathString(), + op.targetFieldName(), + op.description() + ))) + .toList(); + + return new FieldDiagnosticsSummary( + report.fromVersion().getVersion(), + report.toVersion().getVersion(), + report.totalDuration().toMillis(), + report.fixCount(), + report.totalFieldOperationCount(), + operations + ); + } + /** * Response object containing summary information for all registered DataFixer domains. * @@ -333,7 +409,8 @@ public record DomainSummary(int currentVersion, String status, @Nullable String * *

    This record provides comprehensive details about a specific domain, * returned by the {@link #domainDetails(String)} operation. It includes - * the domain name for clarity, along with version and status information.

    + * the domain name for clarity, version, status information, and optionally + * field-level diagnostics from the most recent diagnostic migration.

    * *

    JSON Serialization

    *

    When serialized to JSON, this record produces:

    @@ -341,14 +418,24 @@ public record DomainSummary(int currentVersion, String status, @Nullable String * { * "domain": "game", * "currentVersion": 150, - * "status": "UP" + * "status": "UP", + * "lastDiagnostics": { + * "fromVersion": 100, + * "toVersion": 150, + * "durationMs": 42, + * "fixCount": 2, + * "fieldOperationCount": 5, + * "fieldOperations": [...] + * } * } * } * - * @param domain the domain name (echoed from the request path) - * @param currentVersion the current schema version of the domain, or -1 on error - * @param status the operational status ("UP" or "DOWN") - * @param error the error message if status is "DOWN", or {@code null} if healthy + * @param domain the domain name (echoed from the request path) + * @param currentVersion the current schema version of the domain, or -1 on error + * @param status the operational status ("UP" or "DOWN") + * @param error the error message if status is "DOWN", or {@code null} if healthy + * @param lastDiagnostics summary of the most recent diagnostic migration, or {@code null} + * if no diagnostic migration has been performed * @author Erik Pförtner * @since 0.4.0 */ @@ -356,7 +443,58 @@ public record DomainDetails( String domain, int currentVersion, String status, - @Nullable String error + @Nullable String error, + @Nullable FieldDiagnosticsSummary lastDiagnostics + ) { + } + + /** + * Summary of field-level diagnostics from the most recent diagnostic migration. + * + *

    This record provides an overview of what field operations were performed + * during the last migration that had diagnostics enabled.

    + * + * @param fromVersion the source version of the diagnostic migration + * @param toVersion the target version of the diagnostic migration + * @param durationMs the migration duration in milliseconds + * @param fixCount the number of fixes applied + * @param fieldOperationCount the total number of field operations + * @param fieldOperations the individual field operation summaries + * @author Erik Pförtner + * @since 1.0.0 + */ + public record FieldDiagnosticsSummary( + int fromVersion, + int toVersion, + long durationMs, + int fixCount, + int fieldOperationCount, + List fieldOperations + ) { + + /** + * Compact constructor that creates a defensive copy of the field operations list. + */ + public FieldDiagnosticsSummary { + fieldOperations = fieldOperations != null ? List.copyOf(fieldOperations) : List.of(); + } + } + + /** + * Summary of a single field-level operation. + * + * @param type the operation type (e.g., "RENAME", "REMOVE", "ADD", "TRANSFORM") + * @param field the affected field path in dot-notation + * @param target the target field name for operations that have one, or {@code null} + * @param description optional description providing additional context, or {@code null} + * @author Erik Pförtner + * @since 1.0.0 + */ + public record FieldOperationSummary( + String type, + String field, + @Nullable String target, + @Nullable String description ) { } } diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/ActuatorAutoConfiguration.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/ActuatorAutoConfiguration.java index 4f494cb..02cb485 100644 --- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/ActuatorAutoConfiguration.java +++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/autoconfigure/ActuatorAutoConfiguration.java @@ -27,6 +27,8 @@ import de.splatgames.aether.datafixers.spring.actuator.DataFixerHealthIndicator; import de.splatgames.aether.datafixers.spring.actuator.DataFixerInfoContributor; import de.splatgames.aether.datafixers.spring.metrics.MigrationMetrics; +import de.splatgames.aether.datafixers.spring.service.DefaultMigrationService; +import de.splatgames.aether.datafixers.spring.service.DiagnosticReportStore; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator; @@ -241,18 +243,26 @@ static class EndpointConfiguration { *

    The endpoint provides two operations:

    *
      *
    • GET /actuator/datafixers - Summary of all domains
    • - *
    • GET /actuator/datafixers/{domain} - Details for specific domain
    • + *
    • GET /actuator/datafixers/{domain} - Details for specific domain + * (includes field-level diagnostics from last diagnostic migration)
    • *
    * - * @param registry the DataFixer registry for querying domain information + * @param registry the DataFixer registry for querying domain information + * @param migrationService the migration service providing diagnostic report storage, + * may be {@code null} if not available * @return a new DataFixerEndpoint instance */ @Bean @ConditionalOnMissingBean public DataFixerEndpoint dataFixerEndpoint( - final DataFixerRegistry registry + final DataFixerRegistry registry, + @org.springframework.beans.factory.annotation.Autowired(required = false) + final DefaultMigrationService migrationService ) { - return new DataFixerEndpoint(registry); + final DiagnosticReportStore store = migrationService != null + ? migrationService.getDiagnosticReportStore() + : null; + return new DataFixerEndpoint(registry, store); } } 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 f590fc2..4b39c2e 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 @@ -24,6 +24,9 @@ import com.google.common.base.Preconditions; import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticContext; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import de.splatgames.aether.datafixers.api.dynamic.DynamicOps; import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic; import de.splatgames.aether.datafixers.core.AetherDataFixer; @@ -128,6 +131,12 @@ public class DefaultMigrationService implements MigrationService { */ private final Executor asyncExecutor; + /** + * Store for the most recent diagnostic reports per domain. + * Used by the actuator endpoint to expose field-level diagnostics. + */ + private final DiagnosticReportStore diagnosticReportStore = new DiagnosticReportStore(); + /** * Creates a new DefaultMigrationService with the common ForkJoinPool for async operations. * @@ -241,6 +250,22 @@ public Set getAvailableDomains() { return this.registry.getDomains(); } + /** + * Returns the diagnostic report store containing the most recent + * diagnostic reports per domain. + * + *

    This store is populated when migrations are executed with diagnostics + * enabled via {@link MigrationRequestBuilder#withDiagnostics()}. It is + * used by the actuator endpoint to expose field-level diagnostic information.

    + * + * @return the diagnostic report store, never {@code null} + * @since 1.0.0 + */ + @NotNull + public DiagnosticReportStore getDiagnosticReportStore() { + return this.diagnosticReportStore; + } + /** * Internal implementation of the migration request builder. * @@ -302,6 +327,13 @@ private class DefaultMigrationRequestBuilder implements MigrationRequestBuilder @Nullable private DynamicOps ops; + /** + * Optional diagnostic options. When set, a DiagnosticContext is created + * and passed to the fixer, and the resulting report is included in the result. + */ + @Nullable + private DiagnosticOptions diagnosticOptions; + /** * Creates a new builder for the given input data. * @@ -382,6 +414,21 @@ public MigrationRequestBuilder withOps(@NotNull final DynamicOps ops) { return this; } + /** + * {@inheritDoc} + * + * @param options the diagnostic options, must not be {@code null} + * @return this builder for method chaining + * @throws NullPointerException if options is {@code null} + * @since 1.0.0 + */ + @Override + @NotNull + public MigrationRequestBuilder withDiagnostics(@NotNull final DiagnosticOptions options) { + this.diagnosticOptions = Preconditions.checkNotNull(options, "options must not be null"); + return this; + } + /** * {@inheritDoc} * @@ -425,7 +472,14 @@ public MigrationResult execute() { // Convert to target format if custom ops are specified final TaggedDynamic inputData = convertToTargetOps(this.data); - final TaggedDynamic result = fixer.update(inputData, from, to); + // Create diagnostic context if diagnostics are enabled + final DiagnosticContext diagCtx = this.diagnosticOptions != null + ? DiagnosticContext.create(this.diagnosticOptions) + : null; + + final TaggedDynamic result = diagCtx != null + ? fixer.update(inputData, from, to, diagCtx) + : fixer.update(inputData, from, to); final Duration duration = Duration.between(start, Instant.now()); LOG.debug("Migration completed successfully in {}ms", duration.toMillis()); @@ -436,7 +490,17 @@ public MigrationResult execute() { this.domain, from.getVersion(), to.getVersion(), duration); } - return MigrationResult.success(result, from, to, this.domain, duration); + final MigrationReport diagnosticReport = + diagCtx != null ? diagCtx.getReport() : null; + + // Store diagnostic report for actuator endpoint + if (diagnosticReport != null) { + DefaultMigrationService.this.diagnosticReportStore + .store(this.domain, diagnosticReport); + } + + return MigrationResult.success( + result, from, to, this.domain, duration, diagnosticReport); } catch (final Exception e) { final Duration duration = Duration.between(start, Instant.now()); diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStore.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStore.java new file mode 100644 index 0000000..44ee928 --- /dev/null +++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStore.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.spring.service; + +import com.google.common.base.Preconditions; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe store for the most recent {@link MigrationReport} per domain. + * + *

    This store retains only the latest diagnostic report for each domain, + * replacing any previous report when a new migration with diagnostics + * completes. It is used by the actuator endpoint to expose field-level + * diagnostic information.

    + * + *

    Thread Safety

    + *

    This class is thread-safe. All operations use a {@link ConcurrentHashMap} + * for safe concurrent access from multiple threads.

    + * + * @author Erik Pförtner + * @see MigrationReport + * @see de.splatgames.aether.datafixers.spring.actuator.DataFixerEndpoint + * @since 1.0.0 + */ +public class DiagnosticReportStore { + + /** + * Map of domain names to their most recent diagnostic reports. + */ + private final Map reports = new ConcurrentHashMap<>(); + + /** + * Stores a diagnostic report for the specified domain. + * + *

    Replaces any previously stored report for this domain.

    + * + * @param domain the domain name, must not be {@code null} + * @param report the migration report to store, must not be {@code null} + * @throws NullPointerException if any argument is {@code null} + */ + public void store(@NotNull final String domain, @NotNull final MigrationReport report) { + Preconditions.checkNotNull(domain, "domain must not be null"); + Preconditions.checkNotNull(report, "report must not be null"); + this.reports.put(domain, report); + } + + /** + * Retrieves the most recent diagnostic report for the specified domain. + * + * @param domain the domain name, must not be {@code null} + * @return an Optional containing the report, or empty if no report is stored + * @throws NullPointerException if domain is {@code null} + */ + @NotNull + public Optional get(@NotNull final String domain) { + Preconditions.checkNotNull(domain, "domain must not be null"); + return Optional.ofNullable(this.reports.get(domain)); + } + + /** + * Returns the set of domains that have stored diagnostic reports. + * + * @return an unmodifiable snapshot of domain names with reports + */ + @NotNull + public Set getDomains() { + return Set.copyOf(this.reports.keySet()); + } +} diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationResult.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationResult.java index 7ed9b9c..a7f82e7 100644 --- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationResult.java +++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationResult.java @@ -24,6 +24,7 @@ import com.google.common.base.Preconditions; import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -173,16 +174,26 @@ public final class MigrationResult { @Nullable private final Throwable error; + /** + * The diagnostic migration report. Only present when diagnostics + * were enabled via {@link MigrationService.MigrationRequestBuilder#withDiagnostics()}. + * + * @since 1.0.0 + */ + @Nullable + private final MigrationReport diagnosticReport; + /** * Private constructor to enforce factory method usage. * - * @param success whether the migration succeeded - * @param data the migrated data (null on failure) - * @param fromVersion the source version - * @param toVersion the target version - * @param domain the domain name - * @param duration the migration duration - * @param error the error (null on success) + * @param success whether the migration succeeded + * @param data the migrated data (null on failure) + * @param fromVersion the source version + * @param toVersion the target version + * @param domain the domain name + * @param duration the migration duration + * @param error the error (null on success) + * @param diagnosticReport the diagnostic report (null if diagnostics were not enabled) */ private MigrationResult( final boolean success, @@ -191,7 +202,8 @@ private MigrationResult( @NotNull final DataVersion toVersion, @NotNull final String domain, @NotNull final Duration duration, - @Nullable final Throwable error + @Nullable final Throwable error, + @Nullable final MigrationReport diagnosticReport ) { this.success = success; this.data = data; @@ -200,6 +212,7 @@ private MigrationResult( this.domain = Preconditions.checkNotNull(domain, "domain must not be null"); this.duration = Preconditions.checkNotNull(duration, "duration must not be null"); this.error = error; + this.diagnosticReport = diagnosticReport; } /** @@ -223,9 +236,39 @@ public static MigrationResult success( @NotNull final DataVersion toVersion, @NotNull final String domain, @NotNull final Duration duration + ) { + return success(data, fromVersion, toVersion, domain, duration, null); + } + + /** + * Creates a successful migration result with an optional diagnostic report. + * + *

    Use this factory method when a migration completes without errors and + * diagnostics were optionally enabled. The diagnostic report is accessible + * via {@link #getDiagnosticReport()}.

    + * + * @param data the migrated data, must not be {@code null} + * @param fromVersion the source version, must not be {@code null} + * @param toVersion the target version, must not be {@code null} + * @param domain the domain name used, must not be {@code null} + * @param duration the migration duration, must not be {@code null} + * @param diagnosticReport the diagnostic report, or {@code null} if diagnostics were not enabled + * @return a success result containing the migrated data and optional diagnostics + * @throws NullPointerException if any required parameter is {@code null} + * @since 1.0.0 + */ + @NotNull + public static MigrationResult success( + @NotNull final TaggedDynamic data, + @NotNull final DataVersion fromVersion, + @NotNull final DataVersion toVersion, + @NotNull final String domain, + @NotNull final Duration duration, + @Nullable final MigrationReport diagnosticReport ) { Preconditions.checkNotNull(data, "data must not be null"); - return new MigrationResult(true, data, fromVersion, toVersion, domain, duration, null); + return new MigrationResult(true, data, fromVersion, toVersion, domain, duration, + null, diagnosticReport); } /** @@ -252,7 +295,7 @@ public static MigrationResult failure( @NotNull final Throwable error ) { Preconditions.checkNotNull(error, "error must not be null"); - return new MigrationResult(false, null, fromVersion, toVersion, domain, duration, error); + return new MigrationResult(false, null, fromVersion, toVersion, domain, duration, error, null); } /** @@ -412,6 +455,38 @@ public Optional getError() { return Optional.ofNullable(this.error); } + /** + * Returns the diagnostic migration report, if diagnostics were enabled. + * + *

    The report contains detailed information about each fix execution, + * rule application, and field-level operation that occurred during the + * migration. The Optional will be empty if diagnostics were not enabled + * via {@link MigrationService.MigrationRequestBuilder#withDiagnostics()}.

    + * + *

    Example Usage

    + *
    {@code
    +     * result.getDiagnosticReport().ifPresent(report -> {
    +     *     System.out.println("Fixes applied: " + report.fixCount());
    +     *     System.out.println("Field operations: " + report.totalFieldOperationCount());
    +     *     report.fixExecutions().forEach(fix ->
    +     *         fix.allFieldOperations().forEach(op ->
    +     *             System.out.println("  " + op.toSummary())
    +     *         )
    +     *     );
    +     * });
    +     * }
    + * + * @return an Optional containing the diagnostic report if diagnostics were enabled, + * empty otherwise + * @since 1.0.0 + * @see MigrationService.MigrationRequestBuilder#withDiagnostics() + * @see MigrationReport + */ + @NotNull + public Optional getDiagnosticReport() { + return Optional.ofNullable(this.diagnosticReport); + } + /** * Returns the version span (absolute difference between target and source versions). * diff --git a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationService.java b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationService.java index 45d48ea..3c39e55 100644 --- a/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationService.java +++ b/aether-datafixers-spring-boot-starter/src/main/java/de/splatgames/aether/datafixers/spring/service/MigrationService.java @@ -23,6 +23,7 @@ package de.splatgames.aether.datafixers.spring.service; import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.diagnostic.DiagnosticOptions; import de.splatgames.aether.datafixers.api.dynamic.DynamicOps; import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic; import org.jetbrains.annotations.NotNull; @@ -368,6 +369,51 @@ default MigrationRequestBuilder to(final int version) { @NotNull MigrationRequestBuilder withOps(@NotNull DynamicOps ops); + /** + * Enables diagnostic capture with default options during migration. + * + *

    When enabled, the migration will capture detailed diagnostic information + * including fix executions, rule applications, and field-level operations. + * The diagnostic report is accessible via + * {@link MigrationResult#getDiagnosticReport()} after execution.

    + * + *

    This is equivalent to calling + * {@code withDiagnostics(DiagnosticOptions.defaults())} with snapshots disabled + * for performance.

    + * + * @return this builder for method chaining + * @since 1.0.0 + * @see DiagnosticOptions + * @see MigrationResult#getDiagnosticReport() + */ + @NotNull + default MigrationRequestBuilder withDiagnostics() { + return withDiagnostics(DiagnosticOptions.builder() + .captureSnapshots(false) + .captureRuleDetails(true) + .captureFieldDetails(true) + .build()); + } + + /** + * Enables diagnostic capture with the specified options during migration. + * + *

    The provided options control what diagnostic data is captured, allowing + * fine-tuning of the balance between detail and performance. The diagnostic + * report is accessible via {@link MigrationResult#getDiagnosticReport()} + * after execution.

    + * + * @param options the diagnostic options controlling what data is captured, + * must not be {@code null} + * @return this builder for method chaining + * @throws NullPointerException if options is {@code null} + * @since 1.0.0 + * @see DiagnosticOptions + * @see MigrationResult#getDiagnosticReport() + */ + @NotNull + MigrationRequestBuilder withDiagnostics(@NotNull DiagnosticOptions options); + /** * Executes the configured migration synchronously. * diff --git a/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpointTest.java b/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpointTest.java index 0a687f6..57241b5 100644 --- a/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpointTest.java +++ b/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/actuator/DataFixerEndpointTest.java @@ -23,13 +23,25 @@ package de.splatgames.aether.datafixers.spring.actuator; import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.diagnostic.FieldOperation; +import de.splatgames.aether.datafixers.api.diagnostic.FixExecution; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import de.splatgames.aether.datafixers.api.diagnostic.RuleApplication; import de.splatgames.aether.datafixers.core.AetherDataFixer; import de.splatgames.aether.datafixers.spring.autoconfigure.DataFixerRegistry; +import de.splatgames.aether.datafixers.spring.service.DiagnosticReportStore; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; @@ -202,12 +214,115 @@ void domainSummaryRecordWorks() { @Test @DisplayName("DomainDetails record works correctly") void domainDetailsRecordWorks() { - var details = new DataFixerEndpoint.DomainDetails("game", 200, "UP", null); + var details = new DataFixerEndpoint.DomainDetails("game", 200, "UP", null, null); assertThat(details.domain()).isEqualTo("game"); assertThat(details.currentVersion()).isEqualTo(200); assertThat(details.status()).isEqualTo("UP"); assertThat(details.error()).isNull(); + assertThat(details.lastDiagnostics()).isNull(); + } + } + + @Nested + @DisplayName("Field Diagnostics") + class FieldDiagnostics { + + @Test + @DisplayName("domain details includes diagnostics when store has report") + void includesDiagnosticsFromStore() { + DataFixerRegistry reg = new DataFixerRegistry(); + DiagnosticReportStore store = new DiagnosticReportStore(); + + AetherDataFixer fixer = mock(AetherDataFixer.class); + when(fixer.currentVersion()).thenReturn(new DataVersion(200)); + reg.register("game", fixer); + + // Create a mock MigrationReport + MigrationReport report = mock(MigrationReport.class); + when(report.fromVersion()).thenReturn(new DataVersion(100)); + when(report.toVersion()).thenReturn(new DataVersion(200)); + when(report.totalDuration()).thenReturn(Duration.ofMillis(42)); + when(report.fixCount()).thenReturn(1); + when(report.totalFieldOperationCount()).thenReturn(2); + + FixExecution fix = mock(FixExecution.class); + when(fix.allFieldOperations()).thenReturn(List.of( + FieldOperation.rename("oldName", "newName"), + FieldOperation.remove("deprecated") + )); + when(report.fixExecutions()).thenReturn(List.of(fix)); + store.store("game", report); + + DataFixerEndpoint ep = new DataFixerEndpoint(reg, store); + DataFixerEndpoint.DomainDetails details = ep.domainDetails("game"); + + assertThat(details).isNotNull(); + assertThat(details.lastDiagnostics()).isNotNull(); + assertThat(details.lastDiagnostics().fromVersion()).isEqualTo(100); + assertThat(details.lastDiagnostics().toVersion()).isEqualTo(200); + assertThat(details.lastDiagnostics().fixCount()).isEqualTo(1); + assertThat(details.lastDiagnostics().fieldOperationCount()).isEqualTo(2); + assertThat(details.lastDiagnostics().fieldOperations()).hasSize(2); + } + + @Test + @DisplayName("domain details has null diagnostics when store is empty") + void nullDiagnosticsWhenStoreEmpty() { + DataFixerRegistry reg = new DataFixerRegistry(); + DiagnosticReportStore store = new DiagnosticReportStore(); + + AetherDataFixer fixer = mock(AetherDataFixer.class); + when(fixer.currentVersion()).thenReturn(new DataVersion(200)); + reg.register("game", fixer); + + DataFixerEndpoint ep = new DataFixerEndpoint(reg, store); + DataFixerEndpoint.DomainDetails details = ep.domainDetails("game"); + + assertThat(details).isNotNull(); + assertThat(details.lastDiagnostics()).isNull(); + } + + @Test + @DisplayName("domain details has null diagnostics when no store provided") + void nullDiagnosticsWhenNoStore() { + DataFixerRegistry reg = new DataFixerRegistry(); + AetherDataFixer fixer = mock(AetherDataFixer.class); + when(fixer.currentVersion()).thenReturn(new DataVersion(200)); + reg.register("game", fixer); + + DataFixerEndpoint ep = new DataFixerEndpoint(reg); + DataFixerEndpoint.DomainDetails details = ep.domainDetails("game"); + + assertThat(details).isNotNull(); + assertThat(details.lastDiagnostics()).isNull(); + } + + @Test + @DisplayName("FieldOperationSummary record works correctly") + void fieldOperationSummaryRecordWorks() { + var summary = new DataFixerEndpoint.FieldOperationSummary( + "RENAME", "oldName", "newName", null); + + assertThat(summary.type()).isEqualTo("RENAME"); + assertThat(summary.field()).isEqualTo("oldName"); + assertThat(summary.target()).isEqualTo("newName"); + assertThat(summary.description()).isNull(); + } + + @Test + @DisplayName("FieldDiagnosticsSummary defensive copy") + void fieldDiagnosticsSummaryDefensiveCopy() { + var ops = List.of( + new DataFixerEndpoint.FieldOperationSummary("RENAME", "a", "b", null) + ); + var summary = new DataFixerEndpoint.FieldDiagnosticsSummary( + 100, 200, 42, 1, 1, ops); + + assertThat(summary.fieldOperations()).hasSize(1); + assertThat(summary.fromVersion()).isEqualTo(100); + assertThat(summary.toVersion()).isEqualTo(200); + assertThat(summary.durationMs()).isEqualTo(42); } } } diff --git a/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStoreTest.java b/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStoreTest.java new file mode 100644 index 0000000..4a40b76 --- /dev/null +++ b/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/DiagnosticReportStoreTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.spring.service; + +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link DiagnosticReportStore}. + * + * @author Erik Pförtner + * @since 1.0.0 + */ +@DisplayName("DiagnosticReportStore") +class DiagnosticReportStoreTest { + + private DiagnosticReportStore store; + + @BeforeEach + void setUp() { + store = new DiagnosticReportStore(); + } + + @Test + @DisplayName("returns empty for unknown domain") + void returnsEmptyForUnknownDomain() { + assertThat(store.get("unknown")).isEmpty(); + } + + @Test + @DisplayName("stores and retrieves report for domain") + void storesAndRetrievesReport() { + final MigrationReport report = mock(MigrationReport.class); + store.store("game", report); + + assertThat(store.get("game")).contains(report); + } + + @Test + @DisplayName("replaces previous report for same domain") + void replacesPreviousReport() { + final MigrationReport first = mock(MigrationReport.class); + final MigrationReport second = mock(MigrationReport.class); + store.store("game", first); + store.store("game", second); + + assertThat(store.get("game")).contains(second); + } + + @Test + @DisplayName("tracks domains with reports") + void tracksDomains() { + store.store("game", mock(MigrationReport.class)); + store.store("user", mock(MigrationReport.class)); + + assertThat(store.getDomains()).containsExactlyInAnyOrder("game", "user"); + } + + @Test + @DisplayName("rejects null domain") + void rejectsNullDomain() { + assertThatThrownBy(() -> store.store(null, mock(MigrationReport.class))) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("rejects null report") + void rejectsNullReport() { + assertThatThrownBy(() -> store.store("game", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("rejects null domain on get") + void rejectsNullDomainOnGet() { + assertThatThrownBy(() -> store.get(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/MigrationResultTest.java b/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/MigrationResultTest.java index 1680d57..3246860 100644 --- a/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/MigrationResultTest.java +++ b/aether-datafixers-spring-boot-starter/src/test/java/de/splatgames/aether/datafixers/spring/service/MigrationResultTest.java @@ -23,6 +23,7 @@ package de.splatgames.aether.datafixers.spring.service; import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.diagnostic.MigrationReport; import de.splatgames.aether.datafixers.api.dynamic.TaggedDynamic; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -349,4 +350,56 @@ void toStringIncludesErrorForFailure() { .contains("error=Test error"); } } + + @Nested + @DisplayName("Diagnostic Report") + class DiagnosticReportTests { + + @Test + @DisplayName("success result without diagnostics has empty report") + void successWithoutDiagnostics() { + TaggedDynamic data = mock(TaggedDynamic.class); + + MigrationResult result = MigrationResult.success( + data, FROM_VERSION, TO_VERSION, DOMAIN, DURATION + ); + + assertThat(result.getDiagnosticReport()).isEmpty(); + } + + @Test + @DisplayName("success result with diagnostics has report") + void successWithDiagnostics() { + TaggedDynamic data = mock(TaggedDynamic.class); + MigrationReport report = mock(MigrationReport.class); + + MigrationResult result = MigrationResult.success( + data, FROM_VERSION, TO_VERSION, DOMAIN, DURATION, report + ); + + assertThat(result.getDiagnosticReport()).contains(report); + } + + @Test + @DisplayName("success result with null report has empty optional") + void successWithNullReport() { + TaggedDynamic data = mock(TaggedDynamic.class); + + MigrationResult result = MigrationResult.success( + data, FROM_VERSION, TO_VERSION, DOMAIN, DURATION, null + ); + + assertThat(result.getDiagnosticReport()).isEmpty(); + } + + @Test + @DisplayName("failure result has empty diagnostic report") + void failureHasEmptyDiagnosticReport() { + MigrationResult result = MigrationResult.failure( + FROM_VERSION, TO_VERSION, DOMAIN, DURATION, new RuntimeException("error") + ); + + assertThat(result.getDiagnosticReport()).isEmpty(); + } + } } From 5d13ef14602d2c5c0fdf1006ef664b1f75fa5650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?= Date: Fri, 10 Apr 2026 20:02:19 +0200 Subject: [PATCH 06/11] Refactor formatting for method signatures and annotations across modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Erik Pförtner --- .../cli/command/MigrateCommand.java | 5 +- .../cli/report/JsonReportFormatter.java | 21 ++-- .../cli/report/ReportFormatter.java | 29 +++-- .../cli/report/TextReportFormatter.java | 21 ++-- .../datafixers/core/AetherDataFixer.java | 65 +++++------ .../spring/actuator/DataFixerEndpoint.java | 43 +++---- .../ActuatorAutoConfiguration.java | 16 +-- .../spring/service/MigrationResult.java | 109 ++++++++---------- .../spring/service/MigrationService.java | 16 +-- 9 files changed, 144 insertions(+), 181 deletions(-) diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java index 0d39203..8122345 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/command/MigrateCommand.java @@ -50,6 +50,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; @@ -546,8 +547,8 @@ public Integer call() { if (this.reportFile != null) { Files.writeString(this.reportFile.toPath(), diagnosticContent, StandardCharsets.UTF_8, - java.nio.file.StandardOpenOption.CREATE, - java.nio.file.StandardOpenOption.APPEND); + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); } else { System.err.println(diagnosticContent); } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java index 7b2a6dd..65782a7 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/JsonReportFormatter.java @@ -107,13 +107,11 @@ public class JsonReportFormatter implements ReportFormatter { */ @Override @NotNull - public String formatSimple( - @NotNull final String fileName, - @NotNull final String type, - final int fromVersion, - final int toVersion, - @NotNull final Duration duration - ) { + public String formatSimple(@NotNull final String fileName, + @NotNull final String type, + final int fromVersion, + final int toVersion, + @NotNull final Duration duration) { Preconditions.checkNotNull(fileName, "fileName must not be null"); Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(duration, "duration must not be null"); @@ -172,14 +170,13 @@ public String formatSimple( * @param type the type reference ID (e.g., "player", "world") * @param report the diagnostic migration report containing fix and field operation details * @return a pretty-printed JSON string representing the diagnostic report + * @since 1.0.0 */ @Override @NotNull - public String formatDiagnostic( - @NotNull final String fileName, - @NotNull final String type, - @NotNull final MigrationReport report - ) { + public String formatDiagnostic(@NotNull final String fileName, + @NotNull final String type, + @NotNull final MigrationReport report) { Preconditions.checkNotNull(fileName, "fileName must not be null"); Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(report, "report must not be null"); diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java index 091613a..3c9e04c 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/ReportFormatter.java @@ -65,13 +65,11 @@ public interface ReportFormatter { * @return the formatted report string */ @NotNull - String formatSimple( - @NotNull String fileName, - @NotNull String type, - int fromVersion, - int toVersion, - @NotNull Duration duration - ); + String formatSimple(@NotNull final String fileName, + @NotNull final String type, + final int fromVersion, + final int toVersion, + @NotNull final Duration duration); /** * Formats a diagnostic migration report including field-level operation details. @@ -84,13 +82,11 @@ String formatSimple( * @param type the type reference ID * @param report the diagnostic migration report * @return the formatted diagnostic report string + * @since 0.1.0 */ @NotNull - String formatDiagnostic( - @NotNull String fileName, - @NotNull String type, - @NotNull MigrationReport report - ); + String formatDiagnostic(@NotNull final String fileName, + @NotNull final String type, @NotNull final MigrationReport report); /** * Gets a formatter by format name. @@ -102,9 +98,10 @@ String formatDiagnostic( static ReportFormatter forFormat(@NotNull final String format) { Preconditions.checkNotNull(format, "format must not be null"); - return switch (format.toLowerCase()) { - case "json" -> new JsonReportFormatter(); - default -> new TextReportFormatter(); - }; + if (format.equalsIgnoreCase("json")) { + return new JsonReportFormatter(); + } else { + return new TextReportFormatter(); + } } } diff --git a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java index cfd2bed..11d3376 100644 --- a/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java +++ b/aether-datafixers-cli/src/main/java/de/splatgames/aether/datafixers/cli/report/TextReportFormatter.java @@ -75,13 +75,11 @@ public class TextReportFormatter implements ReportFormatter { */ @Override @NotNull - public String formatSimple( - @NotNull final String fileName, - @NotNull final String type, - final int fromVersion, - final int toVersion, - @NotNull final Duration duration - ) { + public String formatSimple(@NotNull final String fileName, + @NotNull final String type, + final int fromVersion, + final int toVersion, + @NotNull final Duration duration) { Preconditions.checkNotNull(fileName, "fileName must not be null"); Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(duration, "duration must not be null"); @@ -116,14 +114,13 @@ public String formatSimple( * @param type the type reference ID (e.g., "player", "world") * @param report the diagnostic migration report containing fix and field operation details * @return a multi-line formatted diagnostic report string + * @since 1.0.0 */ @Override @NotNull - public String formatDiagnostic( - @NotNull final String fileName, - @NotNull final String type, - @NotNull final MigrationReport report - ) { + public String formatDiagnostic(@NotNull final String fileName, + @NotNull final String type, + @NotNull final MigrationReport report) { Preconditions.checkNotNull(fileName, "fileName must not be null"); Preconditions.checkNotNull(type, "type must not be null"); Preconditions.checkNotNull(report, "report must not be null"); diff --git a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java index 996837b..296f431 100644 --- a/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java +++ b/aether-datafixers-core/src/main/java/de/splatgames/aether/datafixers/core/AetherDataFixer.java @@ -42,8 +42,8 @@ * High-level facade for the Aether DataFixers system. * *

    {@code AetherDataFixer} provides a unified interface for encoding, decoding, - * and migrating data across versions. It combines a {@link SchemaRegistry} for - * type definitions with a {@link DataFixer} for version migrations.

    + * and migrating data across versions. It combines a {@link SchemaRegistry} for type definitions with a + * {@link DataFixer} for version migrations.

    * *

    Core Operations

    *