ValidateVerbExtension.java
/*-
* ========================LICENSE_START=================================
* flyway-verb-validate
* ========================================================================
* 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.validate;
import java.util.ArrayList;
import java.util.List;
import lombok.CustomLog;
import java.util.Set;
import java.util.stream.Collectors;
import org.flywaydb.core.api.CoreErrorCode;
import org.flywaydb.core.api.ErrorDetails;
import org.flywaydb.core.api.MigrationInfo;
import org.flywaydb.core.api.MigrationState;
import org.flywaydb.core.api.MigrationVersion;
import org.flywaydb.core.api.callback.Event;
import org.flywaydb.core.api.configuration.Configuration;
import org.flywaydb.core.api.output.ValidateOutput;
import org.flywaydb.core.api.output.ValidateResult;
import org.flywaydb.core.internal.nc.NativeConnectorsDatabase;
import org.flywaydb.core.extensibility.CachingVerbExtension;
import org.flywaydb.core.internal.license.VersionPrinter;
import org.flywaydb.core.internal.util.StopWatch;
import org.flywaydb.core.internal.util.TimeFormat;
import org.flywaydb.core.internal.util.Pair;
import org.flywaydb.core.internal.util.ValidatePatternUtils;
import org.flywaydb.nc.callbacks.CallbackManager;
import org.flywaydb.nc.utils.VerbUtils;
import org.flywaydb.nc.preparation.PreparationContext;
@CustomLog
public class ValidateVerbExtension extends CachingVerbExtension {
@Override
public boolean handlesVerb(final String verb) {
return "validate".equals(verb);
}
@Override
public Object executeVerb(final Configuration configuration) {
final StopWatch stopWatch = new StopWatch();
stopWatch.start();
final PreparationContext context = PreparationContext.get(configuration, cached);
final NativeConnectorsDatabase database = context.getDatabase();
final CallbackManager callbackManager = new CallbackManager(configuration, context.getResources());
callbackManager.handleEvent(Event.BEFORE_VALIDATE, database, configuration, context.getParsingContext());
final MigrationInfo[] migrations = context.getMigrations();
if (!database.schemaHistoryTableExists(configuration.getTable())) {
LOG.info("Schema history table "
+ database.quote(database.getCurrentSchema(), configuration.getTable())
+ " does not exist yet");
}
if (!database.isSchemaExists(database.getCurrentSchema())) {
if (migrations.length != 0
&& !ValidatePatternUtils.isPendingIgnored(configuration.getIgnoreMigrationPatterns())) {
final String validationErrorMessage = "Schema "
+ database.doQuote(database.getCurrentSchema())
+ " doesn't exist yet";
final ErrorDetails validationError = new ErrorDetails(CoreErrorCode.SCHEMA_DOES_NOT_EXIST,
validationErrorMessage);
return new ValidateResult(VersionPrinter.getVersion(),
database.getDatabaseMetaData().databaseName(),
validationError,
false,
0,
new ArrayList<>(),
new ArrayList<>());
}
}
stopWatch.stop();
final List<MigrationInfo> notIgnoredMigrations = VerbUtils.removeIgnoredMigrations(configuration, migrations);
final List<ValidateOutput> invalidMigrations = getInvalidMigrations(notIgnoredMigrations, configuration);
if (invalidMigrations.isEmpty()) {
final int count = migrations.length;
LOG.info(String.format("Successfully validated %d migration%s (execution time %s)",
count,
count == 1 ? "" : "s",
TimeFormat.format(stopWatch.getTotalTimeMillis())));
callbackManager.handleEvent(Event.AFTER_VALIDATE, database, configuration, context.getParsingContext());
if (migrations.length == 0) {
final ArrayList<String> warnings = new ArrayList<>();
final String noMigrationsWarning = "No migrations found. Are your locations set up correctly?";
warnings.add(noMigrationsWarning);
LOG.warn(noMigrationsWarning);
return new ValidateResult(VersionPrinter.getVersion(),
database.getDatabaseMetaData().databaseName(),
null,
true,
migrations.length,
new ArrayList<>(),
warnings);
}
return new ValidateResult(VersionPrinter.getVersion(),
database.getDatabaseMetaData().databaseName(),
null,
true,
migrations.length,
invalidMigrations,
new ArrayList<>());
}
callbackManager.handleEvent(Event.AFTER_VALIDATE_ERROR, database, configuration, context.getParsingContext());
return new ValidateResult(VersionPrinter.getVersion(),
database.getDatabaseMetaData().databaseName(),
new ErrorDetails(CoreErrorCode.VALIDATE_ERROR, "Migrations have failed validation"),
false,
0,
invalidMigrations,
new ArrayList<>());
}
private List<ValidateOutput> getInvalidMigrations(List<MigrationInfo> migrations, Configuration configuration) {
final boolean pendingIgnored = ValidatePatternUtils.isPendingIgnored(configuration.getIgnoreMigrationPatterns());
final boolean futureIgnored = ValidatePatternUtils.isFutureIgnored(configuration.getIgnoreMigrationPatterns());
final MigrationVersion appliedBaselineVersion = getAppliedBaselineVersion(migrations);
List<ValidateOutput> result = new ArrayList<>();
result.addAll(getTypeMismatch(migrations, appliedBaselineVersion));
result.addAll(getChecksumChanged(migrations, pendingIgnored, appliedBaselineVersion));
result.addAll(getDescriptionChanged(migrations, appliedBaselineVersion));
result.addAll(getOutdatedRepeatables(migrations, pendingIgnored));
result.addAll(getMissingAndFutureSuccessMigrations(migrations, futureIgnored));
result.addAll(getMissingSuccessRepeatables(migrations));
result.addAll(getFailedVersionedMigrations(migrations, futureIgnored));
result.addAll(getFailedRepeatableMigrations(migrations));
result.addAll(getNotIgnoredIgnored(migrations, configuration, result));
result.addAll(getPendingVersionedMigrations(migrations, pendingIgnored));
result.addAll(getPendingRepeatableMigrations(migrations, pendingIgnored));
return result;
}
private static MigrationVersion getAppliedBaselineVersion(final List<MigrationInfo> migrations) {
return migrations.stream()
.filter(x -> x.getType().isBaseline())
.map(MigrationInfo::getVersion)
.max(MigrationVersion::compareTo)
.orElse(null);
}
private static List<ValidateOutput> getOutdatedRepeatables(final List<MigrationInfo> migrations,
boolean pendingIgnored) {
return migrations.stream()
.filter(x -> !pendingIgnored)
.filter(x -> x.getState() == MigrationState.OUTDATED)
.map(x -> new ValidateOutput("",
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.OUTDATED_REPEATABLE_MIGRATION,
"Detected outdated resolved repeatable migration that should be re-applied to database: "
+ x.getDescription())))
.toList();
}
private static List<ValidateOutput> getTypeMismatch(final List<MigrationInfo> migrations,
final MigrationVersion appliedBaselineVersion) {
return migrations.stream()
.filter(x -> !x.isTypeMatching())
.filter(x -> x.isRepeatable() || x.getVersion().isNewerThan(appliedBaselineVersion))
.map(ValidateVerbExtension::typeMismatchesToValidateOutput)
.toList();
}
private static ValidateOutput typeMismatchesToValidateOutput(MigrationInfo mismatch) {
final StringBuilder errorMessage = new StringBuilder();
final MigrationVersion version = mismatch.getVersion();
errorMessage.append("Detected type mismatch for migration version ");
errorMessage.append(version.getVersion());
errorMessage.append("\n-> ");
errorMessage.append("Applied to database on ");
errorMessage.append(mismatch.getInstalledOn());
errorMessage.append(" (");
errorMessage.append(mismatch.getAppliedType().name());
errorMessage.append(")");
errorMessage.append("Resolved locally at: ");
errorMessage.append(mismatch.getPhysicalLocation());
errorMessage.append(" (");
errorMessage.append(mismatch.getResolvedType().name());
errorMessage.append(")");
errorMessage.append("\nEither revert the changes to the migration, or run repair to update the schema history.");
return new ValidateOutput(version.getVersion(),
mismatch.getDescription(),
mismatch.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.TYPE_MISMATCH, errorMessage.toString()));
}
private static List<ValidateOutput> getChecksumChanged(final List<MigrationInfo> migrations,
final boolean pendingIgnored,
final MigrationVersion appliedBaselineVersion) {
return migrations.stream()
.filter(x -> x.isVersioned() || x.getState() != MigrationState.OUTDATED && pendingIgnored)
.filter(x -> x.isVersioned() || x.getState() != MigrationState.SUPERSEDED && pendingIgnored)
.filter(x -> x.isRepeatable() || x.getVersion().compareTo(appliedBaselineVersion) > 0)
.filter(migrationInfo -> !migrationInfo.isChecksumMatching())
.map(x -> {
final String migrationIdentifier = x.isVersioned()
? "version " + x.getVersion().getVersion()
: x.getScript();
return new ValidateOutput(x.isVersioned() ? x.getVersion().getVersion() : "",
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.CHECKSUM_MISMATCH,
"Migration checksum mismatch for migration "
+ migrationIdentifier
+ "\n-> Applied to database : "
+ x.getAppliedChecksum()
+ "\n-> Resolved locally : "
+ x.getResolvedChecksum()
+ "\nEither revert the changes to the migration, or run repair to update the schema history."));
})
.toList();
}
private static List<ValidateOutput> getDescriptionChanged(final List<MigrationInfo> migrations,
final MigrationVersion appliedBaselineVersion) {
return migrations.stream()
.filter(MigrationInfo::isVersioned)
.filter(x -> x.getVersion().compareTo(appliedBaselineVersion) > 0)
.filter(migrationInfo -> !migrationInfo.isDescriptionMatching())
.map(x -> new ValidateOutput(x.getVersion().getVersion(),
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.DESCRIPTION_MISMATCH,
"Migration description mismatch for migration version "
+ x.getVersion().getVersion()
+ "\n-> Applied to database : "
+ x.getAppliedDescription()
+ "\n-> Resolved locally : "
+ x.getResolvedDescription()
+ "\nEither revert the changes to the migration, or run repair to update the schema history.")))
.toList();
}
private static List<ValidateOutput> getMissingAndFutureSuccessMigrations(final List<MigrationInfo> migrations,
boolean futureIgnored) {
return migrations.stream()
.filter(x -> x.getState() == MigrationState.MISSING_SUCCESS || (!futureIgnored
&& x.getState() == MigrationState.FUTURE_SUCCESS))
.filter(x -> !x.getState().isResolved())
.filter(MigrationInfo::isVersioned)
.map(x -> new ValidateOutput(x.getVersion().getVersion(),
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.APPLIED_VERSIONED_MIGRATION_NOT_RESOLVED,
"Detected applied migration not resolved locally: " + x.getVersion().getVersion())))
.toList();
}
private static List<ValidateOutput> getMissingSuccessRepeatables(final List<MigrationInfo> migrations) {
return migrations.stream()
.filter(x -> x.getState() == MigrationState.MISSING_SUCCESS)
.filter(MigrationInfo::isRepeatable)
.map(x -> new ValidateOutput("",
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.APPLIED_REPEATABLE_MIGRATION_NOT_RESOLVED,
"Detected applied migration not resolved locally: " + x.getDescription() + ".")))
.toList();
}
private static List<ValidateOutput> getNotIgnoredIgnored(final List<MigrationInfo> migrations,
final Configuration configuration,
final List<ValidateOutput> result) {
final boolean isIgnoredIgnored = configuration.getCherryPick() != null || ValidatePatternUtils.isIgnoredIgnored(
configuration.getIgnoreMigrationPatterns());
if (isIgnoredIgnored) {
return List.of();
}
final Set<String> errored = result.stream()
.map(x -> x.version == null ? x.description : x.version)
.collect(Collectors.toSet());
return migrations.stream()
.filter(x -> x.getState() == MigrationState.IGNORED)
.filter(x -> !x.getType().isBaseline())
.filter(x -> !x.getType().isUndo())
.filter(x -> !errored.contains(x.isRepeatable() ? x.getDescription() : x.getVersion().getVersion()))
.filter(MigrationInfo::isShouldExecute)
.map(x -> {
if (x.isVersioned()) {
final String errorMessage = "Detected resolved migration not applied to database: "
+ x.getVersion()
.getVersion()
+ ".\nTo ignore this migration, set -ignoreMigrationPatterns='*:ignored'. To allow executing this migration, set -outOfOrder=true.";
return Pair.of(x,
new ErrorDetails(CoreErrorCode.RESOLVED_VERSIONED_MIGRATION_NOT_APPLIED, errorMessage));
}
final String errorMessage = "Detected resolved repeatable migration not applied to database: "
+ x.getDescription()
+ ".\nTo ignore this migration, set -ignoreMigrationPatterns='*:ignored'.";
return Pair.of(x,
new ErrorDetails(CoreErrorCode.RESOLVED_REPEATABLE_MIGRATION_NOT_APPLIED, errorMessage));
})
.map(x -> new ValidateOutput(x.getLeft().getVersion().getVersion(),
x.getLeft().getDescription(),
x.getLeft().getPhysicalLocation(),
x.getRight()))
.toList();
}
private static List<ValidateOutput> getPendingVersionedMigrations(final List<MigrationInfo> migrations,
boolean pendingIgnored) {
return migrations.stream()
.filter(x -> !pendingIgnored)
.filter(x -> x.getState() == MigrationState.PENDING)
.filter(MigrationInfo::isVersioned)
.map(x -> new ValidateOutput(x.getVersion().getVersion(),
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.RESOLVED_VERSIONED_MIGRATION_NOT_APPLIED,
"Detected resolved migration not applied to database: " + x.getVersion().getVersion())))
.toList();
}
private static List<ValidateOutput> getPendingRepeatableMigrations(final List<MigrationInfo> migrations,
boolean pendingIgnored) {
return migrations.stream()
.filter(x -> !pendingIgnored)
.filter(x -> x.getState() == MigrationState.PENDING)
.filter(MigrationInfo::isRepeatable)
.filter(MigrationInfo::isShouldExecute)
.map(x -> new ValidateOutput("",
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.RESOLVED_REPEATABLE_MIGRATION_NOT_APPLIED,
"Detected resolved repeatable migration not applied to database: " + x.getDescription() + ".")))
.toList();
}
private static List<ValidateOutput> getFailedVersionedMigrations(final List<MigrationInfo> migrations,
boolean futureIgnored) {
return migrations.stream()
.filter(x -> x.getState() == MigrationState.FAILED
|| x.getState() == MigrationState.MISSING_FAILED
|| (!futureIgnored && x.getState() == MigrationState.FUTURE_FAILED))
.filter(MigrationInfo::isVersioned)
.map(x -> new ValidateOutput(x.getVersion().getVersion(),
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.FAILED_VERSIONED_MIGRATION,
"Detected failed migration to version " + x.getVersion().getVersion())))
.toList();
}
private static List<ValidateOutput> getFailedRepeatableMigrations(final List<MigrationInfo> migrations) {
return migrations.stream()
.filter(x -> x.getState() == MigrationState.FAILED || x.getState() == MigrationState.MISSING_FAILED)
.filter(MigrationInfo::isRepeatable)
.map(x -> new ValidateOutput("",
x.getDescription(),
x.getPhysicalLocation(),
new ErrorDetails(CoreErrorCode.FAILED_REPEATABLE_MIGRATION,
"Detected failed repeatable migration: " + x.getDescription())))
.toList();
}
}