Skip to content

Commit

Permalink
feat: Add validate command. (#326)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-simons authored Dec 3, 2021
1 parent 2d3a29a commit 922b865
Show file tree
Hide file tree
Showing 25 changed files with 1,029 additions and 43 deletions.
11 changes: 8 additions & 3 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,14 @@ Migrates Neo4j databases.
applied to the database match the ones available
locally and is on by default.
Commands:
info Retrieves all applied and pending informations, prints them and
exits.
migrate Retrieves all pending migrations, verify and applies them.
clean Removes Neo4j-Migration specific data from the selected schema
database
help Displays help information about the specified command
info Retrieves all applied and pending informations, prints them and
exits.
migrate Retrieves all pending migrations, verify and applies them.
validate Resolves all local migrations and validates the state of the
configured database with them.
----

Here's an example that looks for migrations in a Java package and it's subpackages and in a filesystem location for Cypher based migrations.
Expand Down
2 changes: 1 addition & 1 deletion neo4j-migrations-cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<properties>
<covered-ratio-complexity>0.1</covered-ratio-complexity>
<covered-ratio-instructions>0.03</covered-ratio-instructions>
<executable-suffix />
<executable-suffix/>
<java-module-name>ac.simons.neo4j.migrations.cli</java-module-name>
<name-of-main-class>ac.simons.neo4j.migrations.cli.MigrationsCli</name-of-main-class>
<sonar.coverage.jacoco.xmlReportPaths>${basedir}/../${aggregate.report.dir}</sonar.coverage.jacoco.xmlReportPaths>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
name = "neo4j-migrations",
mixinStandardHelpOptions = true,
description = "Migrates Neo4j databases.",
subcommands = { InfoCommand.class, MigrateCommand.class, CleanCommand.class, CommandLine.HelpCommand.class },
subcommands = { CleanCommand.class, CommandLine.HelpCommand.class, InfoCommand.class, MigrateCommand.class, ValidateCommand.class },
versionProvider = ManifestVersionProvider.class
)
public final class MigrationsCli implements Runnable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2020-2021 the original author or authors.
*
* 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
*
* https://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.
*/
package ac.simons.neo4j.migrations.cli;

import ac.simons.neo4j.migrations.core.Migrations;
import ac.simons.neo4j.migrations.core.ValidationResult;
import ac.simons.neo4j.migrations.core.utils.Messages;
import picocli.CommandLine.Command;
import picocli.CommandLine.ParentCommand;

/**
* The validate command.
*
* @author Michael J. Simons
* @soundtrack Sugarcubes - Life's Too Good
* @since 1.2.0
*/
@Command(name = "validate", description = "Resolves all local migrations and validates the state of the configured database with them.")
final class ValidateCommand extends ConnectedCommand {

@ParentCommand
private MigrationsCli parent;

@Override
public MigrationsCli getParent() {
return parent;
}

@Override
Integer withMigrations(Migrations migrations) {

ValidationResult validationResult = migrations.validate();
MigrationsCli.LOGGER.info(validationResult::prettyPrint);
boolean isValid = validationResult.isValid();
if (!isValid) {
validationResult.getWarnings().forEach(MigrationsCli.LOGGER::info);
MigrationsCli.LOGGER.info(Messages.INSTANCE.get(validationResult.needsRepair() ?
"validation.database_needs_repair" :
"validation.database_is_invalid"));
}
return isValid ? 0 : 1;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright 2020-2021 the original author or authors.
*
* 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
*
* https://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.
*/
package ac.simons.neo4j.migrations.cli;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

import ac.simons.neo4j.migrations.core.Migrations;
import ac.simons.neo4j.migrations.core.ValidationResult;

import java.util.Collections;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

/**
* @soundtrack Sugarcubes - Life's Too Good
*/
class ValidateCommandTest {

@ParameterizedTest
@ValueSource(ints = { 0, 1, 2 })
void shouldInvokeValidate(int levelOfFailure) {

boolean makeItFail = levelOfFailure > 0;

Migrations migrations = mock(Migrations.class);
ValidationResult result = mock(ValidationResult.class);
when(result.prettyPrint()).thenReturn("things have been validated");
if (makeItFail) {
when(result.getWarnings()).thenReturn(Collections.singletonList("a warning"));
when(result.isValid()).thenReturn(false);
if (levelOfFailure == 2) {
when(result.needsRepair()).thenReturn(true);
}
} else {
when(result.isValid()).thenReturn(true);
}
when(migrations.validate()).thenReturn(result);

ValidateCommand cmd = new ValidateCommand();

int exitCode = cmd.withMigrations(migrations);
assertThat(exitCode).isEqualTo(makeItFail ? 1 : 0);

verify(migrations).validate();
verify(result).isValid();
verify(result).prettyPrint();
if (makeItFail) {
verify(result).needsRepair();
verify(result).getWarnings();
}

verifyNoMoreInteractions(migrations, result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@
*/
final class ChainBuilder {

/**
* A flag to force the chain builder into verification mode.
*/
private final boolean alwaysVerify;

ChainBuilder() {
this(false);
}

ChainBuilder(boolean alwaysVerify) {
this.alwaysVerify = alwaysVerify;
}

/**
* @param discoveredMigrations A list of migrations sorted by {@link Migration#getVersion()}.
* It is not yet known whether those are pending or not.
Expand Down Expand Up @@ -109,38 +122,41 @@ class ExtendedResultSummary {
private Map<MigrationVersion, Element> buildChain0(MigrationContext context, List<Migration> discoveredMigrations) {

Map<MigrationVersion, Element> appliedMigrations = getChainOfAppliedMigrations(context);
Map<MigrationVersion, Element> fullMigrationChain = new LinkedHashMap<>(
discoveredMigrations.size() + appliedMigrations.size());

if (discoveredMigrations.isEmpty()) {
// No migrations found, everything in the chain is applied
fullMigrationChain.putAll(appliedMigrations);
} else {
int i = 0;
for (Map.Entry<MigrationVersion, Element> entry : appliedMigrations.entrySet()) {
MigrationVersion expectedVersion = entry.getKey();
Optional<String> expectedChecksum = entry.getValue().getChecksum();

Migration newMigration = discoveredMigrations.get(i);
if (!newMigration.getVersion().equals(expectedVersion)) {
throw new MigrationsException(
"Unexpected migration at index " + i + ": " + Migrations.toString(newMigration));
}

if (context.getConfig().isValidateOnMigrate() && !expectedChecksum.equals(newMigration.getChecksum())) {
throw new MigrationsException(("Checksum of " + Migrations.toString(newMigration) + " changed!"));
}
// This is not a pending migration anymore
fullMigrationChain.put(expectedVersion, entry.getValue());
++i;
Collections.unmodifiableMap(appliedMigrations);
}

Map<MigrationVersion, Element> fullMigrationChain = new LinkedHashMap<>(
discoveredMigrations.size() + appliedMigrations.size());
int i = 0;
for (Map.Entry<MigrationVersion, Element> entry : appliedMigrations.entrySet()) {
MigrationVersion expectedVersion = entry.getKey();
Optional<String> expectedChecksum = entry.getValue().getChecksum();

Migration newMigration;
try {
newMigration = discoveredMigrations.get(i);
} catch (IndexOutOfBoundsException e) {
throw new MigrationsException("More migrations have been applied to the database than locally resolved", e);
}
if (!newMigration.getVersion().equals(expectedVersion)) {
throw new MigrationsException("Unexpected migration at index " + i + ": " + Migrations.toString(newMigration));
}

// All remaining migrations are pending
while (i < discoveredMigrations.size()) {
Migration pendingMigration = discoveredMigrations.get(i++);
Element element = DefaultChainElement.pendingElement(pendingMigration);
fullMigrationChain.put(pendingMigration.getVersion(), element);
if ((context.getConfig().isValidateOnMigrate() || alwaysVerify) && !expectedChecksum.equals(newMigration.getChecksum())) {
throw new MigrationsException("Checksum of " + Migrations.toString(newMigration) + " changed!");
}
// This is not a pending migration anymore
fullMigrationChain.put(expectedVersion, entry.getValue());
++i;
}

// All remaining migrations are pending
while (i < discoveredMigrations.size()) {
Migration pendingMigration = discoveredMigrations.get(i++);
Element element = DefaultChainElement.pendingElement(pendingMigration);
fullMigrationChain.put(pendingMigration.getVersion(), element);
}

return Collections.unmodifiableMap(fullMigrationChain);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* @author Michael J. Simons
* @since 1.1.0
*/
public final class CleanResult implements OperationResult {
public final class CleanResult implements DatabaseOperationResult {

private final String affectedDatabase;

Expand All @@ -52,9 +52,7 @@ public final class CleanResult implements OperationResult {
this.indexesRemoved = indexesRemoved;
}

/**
* @return the optional name of the database clean, an empty optional indicates the default database
*/
@Override
public Optional<String> getAffectedDatabase() {
return Optional.ofNullable(affectedDatabase);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2020-2021 the original author or authors.
*
* 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
*
* https://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.
*/
package ac.simons.neo4j.migrations.core;

import java.util.Optional;

/**
* A specialization of the {@link OperationResult} that always affects a database, either the default one
* or a named other.
*
* @author Michael J. Simons
* @soundtrack Die Krupps - Paradise Now
* @since 1.2.0
*/
public interface DatabaseOperationResult extends OperationResult {

/**
* @return the optional name of the database clean, an empty optional indicates the default database
*/
Optional<String> getAffectedDatabase();
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/
package ac.simons.neo4j.migrations.core;

import ac.simons.neo4j.migrations.core.ValidationResult.Outcome;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collections;
Expand Down Expand Up @@ -183,6 +185,45 @@ private DeletedChainsWithCounters clean0(Optional<String> migrationTarget, boole
}
}

/**
* Validates the database against the resolved migrations. A database is considered to be in a valid state when all
* resolved migrations have been applied (there are no more pending migrations). If a database is not yet fully migrated,
* it won't identify as {@link ValidationResult.Outcome#VALID} but it will indicate via {@link ValidationResult#needsRepair()} that
* it doesn't need repair. Applying the pending migrations via {@link #apply()} will bring the database into a valid state.
* Most other outcomes not valid need to be manually fix. One radical fix is calling {@link Migrations#clean(boolean)}
* with the same configuration.
*
* @return a validation result, with an outcome, a possible list of warnings and indicators if the database is in a valid state
* @since 1.2.0
*/
public ValidationResult validate() {

return executeWithinLock(() -> {
List<Migration> migrations = discoveryService.findMigrations(this.context);
Optional<String> targetDatabase = config.getOptionalSchemaDatabase();
try {
MigrationChain migrationChain = new ChainBuilder(true).buildChain(context, migrations);
int numberOfAppliedMigrations = (int) migrationChain.getElements()
.stream().filter(m -> m.getState() == MigrationState.APPLIED)
.count();
if (migrations.size() == numberOfAppliedMigrations) {
return new ValidationResult(targetDatabase, Outcome.VALID, numberOfAppliedMigrations == 0 ?
Collections.singletonList("No migrations resolved.") :
Collections.emptyList());
} else if (migrations.size() > numberOfAppliedMigrations) {
return new ValidationResult(targetDatabase, Outcome.INCOMPLETE_DATABASE, Collections.emptyList());
}
return new ValidationResult(targetDatabase, Outcome.UNDEFINED, Collections.emptyList());
} catch (MigrationsException e) {
List<String> warnings = Collections.singletonList(e.getMessage());
if (e.getCause() instanceof IndexOutOfBoundsException) {
return new ValidationResult(targetDatabase, Outcome.INCOMPLETE_MIGRATIONS, warnings);
}
return new ValidationResult(targetDatabase, Outcome.DIFFERENT_CONTENT, warnings);
}
});
}

private <T> T executeWithinLock(Supplier<T> executable) {

driver.verifyConnectivity();
Expand Down
Loading

0 comments on commit 922b865

Please sign in to comment.