RepairVerbExtension.java

/*-
 * ========================LICENSE_START=================================
 * flyway-verb-repair
 * ========================================================================
 * Copyright (C) 2010 - 2025 Red Gate Software Ltd
 * ========================================================================
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * =========================LICENSE_END==================================
 */
package org.flywaydb.verb.repair;

import java.util.Arrays;
import java.util.List;
import lombok.CustomLog;
import org.flywaydb.core.api.CoreMigrationType;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.MigrationInfo;
import org.flywaydb.core.api.MigrationState;
import org.flywaydb.core.api.configuration.Configuration;
import org.flywaydb.core.api.output.RepairOutput;
import org.flywaydb.core.api.output.RepairResult;
import org.flywaydb.core.internal.nc.NativeConnectorsDatabase;
import org.flywaydb.core.internal.nc.schemahistory.SchemaHistoryItem;
import org.flywaydb.core.extensibility.VerbExtension;
import org.flywaydb.core.internal.license.VersionPrinter;
import org.flywaydb.core.internal.util.StopWatch;
import org.flywaydb.core.internal.util.TimeFormat;
import org.flywaydb.nc.utils.VerbUtils;
import org.flywaydb.nc.preparation.PreparationContext;

@CustomLog
public class RepairVerbExtension implements VerbExtension {
    @Override
    public boolean handlesVerb(final String verb) {
        return "repair".equals(verb);
    }

    @Override
    public Object executeVerb(final Configuration configuration) {

        final PreparationContext context = PreparationContext.get(configuration, false);

        final NativeConnectorsDatabase database = context.getDatabase();

        final RepairResult repairResult = new RepairResult(VersionPrinter.getVersion(),
            database.getDatabaseMetaData().databaseName());

        final StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        removeFailedMigrations(configuration, repairResult, context);

        markRemovedMigrationsAsDeleted(configuration, repairResult, context);

        alignAppliedMigrationsWithResolvedMigrations(configuration, repairResult, context);

        stopWatch.stop();
        if (repairResult.repairActions.isEmpty()) {
            LOG.info("Repair of schema history table " + database.quote(database.getCurrentSchema(),
                configuration.getTable()) + " not needed, no migrations need repairing.");
        } else {
            LOG.info("Successfully repaired schema history table "
                + database.quote(database.getCurrentSchema(),
                configuration.getTable())
                + " (execution time "
                + TimeFormat.format(stopWatch.getTotalTimeMillis())
                + ").");
            if (repairResult.repairActions.contains("Marked missing migrations as deleted")) {
                LOG.info(
                    "Please ensure the previous contents of the deleted migrations are removed from the database, or moved into an existing migration.");
            }
        }
        return repairResult;
    }

    private void alignAppliedMigrationsWithResolvedMigrations(final Configuration configuration,
        final RepairResult repairResult,
        final PreparationContext context) {

        if (!repairResult.migrationsDeleted.isEmpty()) {
            context.refresh(configuration);
        }

        final List<MigrationInfo> appliedVersionedInfos = Arrays.stream(context.getMigrations())
            .filter(x -> x.getVersion() != null)
            .filter(x -> x.getState().isResolved())
            .filter(x -> x.getState().isApplied())
            .filter(x -> !x.getType().isSynthetic())
            .filter(x -> x.getState() != MigrationState.UNDONE)
            .filter(this::updateNeeded)
            .toList();
        if (!appliedVersionedInfos.isEmpty()) {
            repairResult.migrationsAligned = appliedVersionedInfos.stream()
                .map(x -> new RepairOutput(x.isRepeatable() ? "" : x.getVersion().getVersion(),
                    x.getDescription(),
                    x.getPhysicalLocation()))
                .toList();
            for (final MigrationInfo updatedEntry : appliedVersionedInfos) {
                final SchemaHistoryItem item = context.getSchemaHistoryModel()
                    .getSchemaHistoryItem(updatedEntry.getInstalledRank())
                    .orElseThrow(() -> new FlywayException("Fatal error when repairing, please contact support!"));
                LOG.info("Repairing Schema History table for version "
                    + item.getVersion()
                    + " (Description: "
                    + updatedEntry.getResolvedDescription()
                    + ", Type: "
                    + updatedEntry.getResolvedType()
                    + ", Checksum: "
                    + updatedEntry.getResolvedChecksum()
                    + ")  ...");
                context.getDatabase()
                    .updateSchemaHistoryItem(item.toBuilder()
                        .type(updatedEntry.getResolvedType().name())
                        .checksum(updatedEntry.getResolvedChecksum())
                        .description(updatedEntry.getResolvedDescription())
                        .build(), configuration.getTable());
            }
            repairResult.repairActions.add("Aligned applied migration checksums");
        } else {
            LOG.info("No migrations to realign in Schema History table " + context.getDatabase()
                .quote(context.getDatabase().getCurrentSchema(), configuration.getTable()));
        }
    }

    private static void markRemovedMigrationsAsDeleted(final Configuration configuration,
        final RepairResult repairResult,
        final PreparationContext context) {

        if (!repairResult.migrationsRemoved.isEmpty()) {
            context.refresh(configuration);
        }

        final List<MigrationInfo> migrationInfo = VerbUtils.removeIgnoredMigrations(configuration,
            context.getMigrations()).stream().filter(x -> !x.getState().isResolved()).toList();

        repairResult.migrationsDeleted = migrationInfo.stream()
            .map(x -> new RepairOutput(x.isRepeatable() ? "" : x.getVersion().getVersion(), x.getDescription(), ""))
            .toList();

        if (!repairResult.migrationsDeleted.isEmpty()) {
            int nextInstalledRank = context.getSchemaHistoryModel().calculateInstalledRank(CoreMigrationType.DELETE);
            final List<SchemaHistoryItem> schemaHistoryItems = migrationInfo.stream()
                .map(x -> context.getSchemaHistoryModel().getSchemaHistoryItem(x.getInstalledRank()))
                .map(x -> x.orElseThrow(() -> new FlywayException("Fatal error when repairing, please contact support!")))
                .toList();
            for (final SchemaHistoryItem schemaHistoryItem : schemaHistoryItems) {
                if (schemaHistoryItem.getVersion() == null) {
                    LOG.info("Repairing Schema History table for description \""
                        + schemaHistoryItem.getDescription()
                        + "\" (Marking as DELETED)  ...");
                } else {
                    LOG.info("Repairing Schema History table for version \""
                        + schemaHistoryItem.getVersion()
                        + "\" (Marking as DELETED)  ...");
                }
                context.getDatabase()
                    .appendSchemaHistoryItem(schemaHistoryItem.toBuilder()
                        .type(CoreMigrationType.DELETE.name())
                        .installedRank(nextInstalledRank++)
                        .build(), configuration.getTable());
            }
            repairResult.repairActions.add("Marked missing migrations as deleted");
        } else {
            LOG.info("No missing or future migrations to be marked as deleted in Schema History table "
                + context.getDatabase().quote(context.getDatabase().getCurrentSchema(), configuration.getTable()));
        }
    }

    private static void removeFailedMigrations(final Configuration configuration,
        final RepairResult repairResult,
        final PreparationContext context) {
        repairResult.migrationsRemoved = context.getSchemaHistoryModel()
            .getSchemaHistoryItems()
            .stream()
            .filter(x -> !x.isSuccess())
            .map(x -> new RepairOutput(x.getVersion() == null ? "" : x.getVersion(), x.getDescription(), ""))
            .toList();
        if (!repairResult.migrationsRemoved.isEmpty()) {
            context.getDatabase().removeFailedSchemaHistoryItems(configuration.getTable());

            repairResult.repairActions.add("Removed failed migrations");
            LOG.info("Removed "
                + repairResult.migrationsRemoved.size()
                + " failed migration from Schema History table "
                + context.getDatabase().quote(context.getDatabase().getCurrentSchema(), configuration.getTable()));
        } else {
            LOG.info("Repair of failed migration in Schema History table "
                + context.getDatabase()
                .quote(context.getDatabase().getCurrentSchema(), configuration.getTable())
                + " not necessary. No failed migration detected.");
        }
    }

    private boolean updateNeeded(final MigrationInfo migrationInfo) {
        return !(migrationInfo.isChecksumMatching()
            & migrationInfo.isDescriptionMatching()
            & migrationInfo.isTypeMatching());
    }
}