Skip to content

Commit

Permalink
Merge pull request #346 from gamerover98/master
Browse files Browse the repository at this point in the history
  • Loading branch information
ljacqu authored Aug 10, 2023
2 parents 039970f + 721e200 commit c9b912d
Show file tree
Hide file tree
Showing 10 changed files with 738 additions and 79 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package ch.jalu.configme.migration;

import ch.jalu.configme.configurationdata.ConfigurationData;
import ch.jalu.configme.properties.Property;
import ch.jalu.configme.properties.convertresult.PropertyValue;
import ch.jalu.configme.resource.PropertyReader;
import org.jetbrains.annotations.NotNull;

Expand Down Expand Up @@ -35,30 +33,4 @@ public boolean checkAndMigrate(@NotNull PropertyReader reader, @NotNull Configur
protected boolean performMigrations(@NotNull PropertyReader reader, @NotNull ConfigurationData configurationData) {
return NO_MIGRATION_NEEDED;
}

/**
* Utility method: moves the value of an old property to a new property. This is only done if there is no value for
* the new property in the configuration file and if there is one for the old property. Returns true if a value is
* present at the old property path.
*
* @param oldProperty the old property (create a temporary {@link Property} object with the path)
* @param newProperty the new property to move the value to
* @param reader the property reader to read the configuration file from
* @param configurationData configuration data to update a property's value
* @param <T> the type of the property
* @return true if the old path exists in the configuration file, false otherwise
*/
protected static <T> boolean moveProperty(@NotNull Property<T> oldProperty,
@NotNull Property<T> newProperty,
@NotNull PropertyReader reader,
@NotNull ConfigurationData configurationData) {
if (reader.contains(oldProperty.getPath())) {
if (!reader.contains(newProperty.getPath())) {
PropertyValue<T> value = oldProperty.determineValue(reader);
configurationData.setValue(newProperty, value.getValue());
}
return true;
}
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package ch.jalu.configme.migration.version;

import ch.jalu.configme.configurationdata.ConfigurationData;
import ch.jalu.configme.resource.PropertyReader;
import org.jetbrains.annotations.NotNull;

/**
* A migration used by {@link VersionMigrationService} to migrate from one configuration version to a newer one.
*
* @see VersionMigrationService
*/
public interface VersionMigration {

/**
* @return the configuration version this migration converts from (e.g. 1)
*/
int fromVersion();

/**
* @return the configuration version this migration converts to (e.g. 2)
*/
int targetVersion();

/**
* Migrates the configuration.
*
* @param reader the property reader to read the configuration file from
* @param configurationData configuration data to update a property's value
*/
void migrate(@NotNull PropertyReader reader, @NotNull ConfigurationData configurationData);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package ch.jalu.configme.migration.version;

import ch.jalu.configme.configurationdata.ConfigurationData;
import ch.jalu.configme.migration.MigrationService;
import ch.jalu.configme.properties.Property;
import ch.jalu.configme.resource.PropertyReader;
import org.jetbrains.annotations.NotNull;

import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

/**
* Version-based {@link MigrationService} implementation that uses a {@code Property<Integer>} to track configuration
* versions, based on which it triggers migrations. The application's current configuration value is taken from the
* property's {@link Property#getDefaultValue() default value}, which should be incremented whenever a new migration is
* desired.
* <p>
* To define a migration, create a new implementation of {@link VersionMigration} and provide it to this service's
* constructor. Ensure that each migration's starting version is unique and valid, and that the target version is not
* greater than the default value of the version property.
* <p>
* This service triggers migrations and resaves the configuration if the version read from the configuration file is not
* equal to the version property's default value. Migrations are applied successively from the stored version to the
* target version of each migration, ensuring proper migration order. For example, if a service has a migration from
* version 1 to 2, and one from version 2 to 3, then both migrations are run if the version in the config file is 1.
* On the other hand, if one migration migrates from version 1 to 3 and another one from 2 to 3, then only the former
* would be run in the same scenario.
* <p>
* Regardless of which migrations were run (or if any were run at all), the version in the config file is set to the
* version property's default value at the end of the execution. This ensures that invalid versions (like a value that
* was manually changed) are fixed. Since only known properties are saved to the config file, storing the current
* default value as version most appropriately reflects the structure of the configuration file.
* <p>
* It is recommended to create a migration for each incremental version change for simplicity (i.e. 1 to 2,
* 2 to 3, ...). However, you can also define non-sequential migrations: a migration can migrate from 1 to 4,
* another from 2 to 3, and one from 3 to 4 to migrate any older version to version 4.
*
* @author gamerover98
*/
public class VersionMigrationService implements MigrationService {

/**
* The version {@link Property} of the configuration.
*/
private final Property<Integer> versionProperty;

/**
* All known migrations held by start version.
*/
private final Map<Integer, VersionMigration> migrationsByStartVersion;

/**
* Constructor.
*
* @param versionProperty the property that contains the configuration version
* @param migrations all known migrations
*/
public VersionMigrationService(@NotNull Property<Integer> versionProperty,
@NotNull Iterable<VersionMigration> migrations) {
this.versionProperty = versionProperty;
this.migrationsByStartVersion = validateAndGroupMigrationsByFromVersion(migrations);
}

/**
* Constructor.
*
* @param versionProperty the property that contains the configuration version
* @param migrations all known migrations
*/
public VersionMigrationService(@NotNull Property<Integer> versionProperty,
@NotNull VersionMigration... migrations) {
this(versionProperty, Arrays.asList(migrations));
}

@Override
public boolean checkAndMigrate(@NotNull PropertyReader reader, @NotNull ConfigurationData configurationData) {
return performMigrations(reader, configurationData) || !configurationData.areAllValuesValidInResource();
}

protected final @NotNull Property<Integer> getVersionProperty() {
return versionProperty;
}

protected final @NotNull Map<Integer, VersionMigration> getMigrationsByStartVersion() {
return migrationsByStartVersion;
}

/**
* Performs the migration by using the versioning system.
* <p>
* Note that the settings manager automatically saves the resource
* if the migration service returns {@link #MIGRATION_REQUIRED} from {@link #checkAndMigrate}.
*
* @param reader the reader with which the configuration file can be read
* @param configurationData the configuration data
* @return true if a migration has been performed, false otherwise (see constants on {@link MigrationService})
*/
protected boolean performMigrations(@NotNull PropertyReader reader, @NotNull ConfigurationData configurationData) {
int readConfigVersion = versionProperty.determineValue(reader).getValue();
int configVersion = versionProperty.getDefaultValue();

// No action needed, versions match.
if (readConfigVersion == configVersion) {
return NO_MIGRATION_NEEDED;
}

// Migrate the configuration from version 1 to 2 to 3, and so on
runApplicableMigrations(readConfigVersion, reader, configurationData);
// We set the current version regardless of what migrations were run: if there was no migration for the version
// or the migrations didn't end up at the current config version, triggering a resave still means that all
// stored values correspond to the current structure, so it's safe to assume we're up-to-date.
configurationData.setValue(versionProperty, configVersion);

return MIGRATION_REQUIRED;
}

/**
* Runs applicable migrations successively: if a migration is found for the read config version, it is run and its
* {@link VersionMigration#targetVersion() target version} is noted. If a migration exists for the target version,
* it is also run, and so forth.
*
* @param readConfigVersion the version that was read in the configuration file
* @param reader the reader with which the configuration file can be read
* @param configurationData the configuration data
* @return the target version of the last migration that was run, or the read config version if no migration was run
*/
protected int runApplicableMigrations(int readConfigVersion,
@NotNull PropertyReader reader,
@NotNull ConfigurationData configurationData) {
int updatedVersion = readConfigVersion;
VersionMigration migration = migrationsByStartVersion.get(readConfigVersion);
while (migration != null) {
migration.migrate(reader, configurationData);

updatedVersion = migration.targetVersion();
migration = migrationsByStartVersion.get(updatedVersion);
}
return updatedVersion;
}

protected Map<Integer, VersionMigration> validateAndGroupMigrationsByFromVersion(
Iterable<VersionMigration> migrations) {
Map<Integer, VersionMigration> migrationsByStartVersion = new HashMap<>();
for (VersionMigration migration : migrations) {
validateVersions(migration);
int fromVersion = migration.fromVersion();

if (migrationsByStartVersion.put(fromVersion, migration) != null) {
throw new IllegalArgumentException(
"Multiple migrations were provided for start version " + fromVersion);
}
}
return migrationsByStartVersion;
}

protected void validateVersions(VersionMigration migration) {
if (migration.targetVersion() > versionProperty.getDefaultValue()) {
throw new IllegalArgumentException("The migration from version " + migration.fromVersion() + " to version "
+ migration.targetVersion() + " has an invalid target version. Current configuration version is: "
+ versionProperty.getDefaultValue());
} else if (migration.fromVersion() >= migration.targetVersion()) {
throw new IllegalArgumentException(
"A migration from version " + migration.fromVersion() + " to version " + migration.targetVersion()
+ " was supplied, but it is expected that the target version be larger than the start version");
}
}
}
42 changes: 42 additions & 0 deletions src/main/java/ch/jalu/configme/utils/MigrationUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package ch.jalu.configme.utils;

import ch.jalu.configme.configurationdata.ConfigurationData;
import ch.jalu.configme.properties.Property;
import ch.jalu.configme.properties.convertresult.PropertyValue;
import ch.jalu.configme.resource.PropertyReader;
import org.jetbrains.annotations.NotNull;

/**
* Migration utils.
*/
public final class MigrationUtils {

private MigrationUtils() {
}

/**
* Utility method: moves the value of an old property to a new property. This is only done if there is no value for
* the new property in the configuration file and if there is one for the old property. Returns true if a value is
* present at the old property path.
*
* @param oldProperty the old property (create a temporary {@link Property} object with the path)
* @param newProperty the new property to move the value to
* @param reader the property reader to read the configuration file from
* @param configurationData configuration data to update a property's value
* @param <T> the type of the property
* @return true if the old path exists in the configuration file, false otherwise
*/
public static <T> boolean moveProperty(@NotNull Property<T> oldProperty,
@NotNull Property<T> newProperty,
@NotNull PropertyReader reader,
@NotNull ConfigurationData configurationData) {
if (reader.contains(oldProperty.getPath())) {
if (!reader.contains(newProperty.getPath())) {
PropertyValue<T> value = oldProperty.determineValue(reader);
configurationData.setValue(newProperty, value.getValue());
}
return true;
}
return false;
}
}
75 changes: 72 additions & 3 deletions src/test/java/ch/jalu/configme/SettingsManagerBuilderTest.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package ch.jalu.configme;

import ch.jalu.configme.configurationdata.ConfigurationData;
import ch.jalu.configme.configurationdata.ConfigurationDataBuilder;
import ch.jalu.configme.migration.MigrationService;
import ch.jalu.configme.migration.PlainMigrationService;
import ch.jalu.configme.migration.version.VersionMigration;
import ch.jalu.configme.migration.version.VersionMigrationService;
import ch.jalu.configme.properties.Property;
import ch.jalu.configme.properties.PropertyInitializer;
import ch.jalu.configme.resource.PropertyReader;
import ch.jalu.configme.resource.PropertyResource;
import ch.jalu.configme.resource.YamlFileResource;
import ch.jalu.configme.resource.YamlFileResourceOptions;
import ch.jalu.configme.samples.TestConfiguration;
import ch.jalu.configme.samples.TestVersionConfiguration;
import ch.jalu.configme.utils.MigrationUtils;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

Expand All @@ -17,15 +25,17 @@
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;

import static ch.jalu.configme.TestUtils.copyFileFromResources;
import static ch.jalu.configme.TestUtils.isValidValueOf;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.greaterThan;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
Expand Down Expand Up @@ -156,4 +166,63 @@ void shouldCreateSettingsManagerFromFileObject2() throws URISyntaxException {
// then
assertThat(settingsManager.getProperty(TestConfiguration.SYSTEM_NAME), equalTo("Custom sys name"));
}

/**
* Tests the integration of {@link VersionMigrationService} with the settings manager. A migration is triggered
* by it due to the contents in the YAML file.
*/
@Test
void shouldMigrateFromVersion1ToVersion2() throws IOException {
// given
Path file = copyFileFromResources("/versions/config-old-version-sample.yml", temporaryFolder);
long initialFileSize = Files.size(file);

ConfigurationData configurationData = ConfigurationDataBuilder.createConfiguration(TestVersionConfiguration.class);
MigrationService migrationService = new VersionMigrationService(
TestVersionConfiguration.VERSION_NUMBER, new From1To2VersionMigration());

// when
SettingsManagerImpl manager = (SettingsManagerImpl) SettingsManagerBuilder.withYamlFile(file)
.configurationData(configurationData)
.migrationService(migrationService)
.create();

// then
// check that file was written to
assertThat(Files.size(file), greaterThan(initialFileSize));

PropertyReader reader = manager.getPropertyResource().createReader();

assertThat(TestVersionConfiguration.VERSION_NUMBER.determineValue(reader).getValue(), equalTo(2));
assertThat(TestVersionConfiguration.SHELF_POPATOES.determineValue(reader).getValue(), equalTo(4));
assertThat(TestVersionConfiguration.SHELF_TOMATOES.determineValue(reader).getValue(), equalTo(10));
}

/**
* A simple implementation to migrate the config file from version 1 to version 2.
*/
private static final class From1To2VersionMigration implements VersionMigration {

@Override
public int fromVersion() {
return 1;
}

@Override
public int targetVersion() {
return 2;
}

@Override
public void migrate(@NotNull PropertyReader reader, @NotNull ConfigurationData configurationData) {
Property<Integer> oldPotatoesProperty = PropertyInitializer.newProperty("potatoes", 4);
Property<Integer> oldTomatoesProperty = PropertyInitializer.newProperty("tomatoes", 10);

Property<Integer> newPotatoesProperty = TestVersionConfiguration.SHELF_POPATOES;
Property<Integer> newTomatoesProperty = TestVersionConfiguration.SHELF_TOMATOES;

MigrationUtils.moveProperty(oldPotatoesProperty, newPotatoesProperty, reader, configurationData);
MigrationUtils.moveProperty(oldTomatoesProperty, newTomatoesProperty, reader, configurationData);
}
}
}
Loading

0 comments on commit c9b912d

Please sign in to comment.