added versioning

dev
Exlll 7 years ago
parent 9ae4731de4
commit 108133abc0

@ -1,5 +1,5 @@
name: ConfigLib
author: Exlll
version: 1.3.2
version: 1.4.0
main: de.exlll.configlib.ConfigLib

@ -1,5 +1,5 @@
name: ConfigLib
author: Exlll
version: 1.3.2
version: 1.4.0
main: de.exlll.configlib.ConfigLib

@ -6,10 +6,23 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* The {@code Comment} annotation can be used to add comments to a configuration file.
* Indicates that the annotated element is saved together with explanatory
* comments describing it.
* <p>
* When this annotation is used on a class, the comments returned by its
* {@link #value()} method are saved at the beginning of the configuration file.
* If it's used on a field, the comments are saved above the field name.
*/
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Comment {
/**
* Returns the comments of the annotated type or field.
* <p>
* When the configuration is saved, every comment is written into a new line.
* Empty comments function as newlines.
*
* @return class or field comments
*/
String[] value();
}

@ -29,8 +29,10 @@ final class CommentAdder {
private void addComments(List<String> comments) {
for (String comment : comments) {
builder.append("# ");
builder.append(comment);
if (!comment.trim().isEmpty()) {
builder.append("# ");
builder.append(comment);
}
builder.append('\n');
}
}

@ -13,6 +13,7 @@ final class Comments {
Comments(Class<?> cls) {
this.classComments = getComments(cls);
this.fieldComments = getFieldComments(cls);
addVersionComments(cls);
}
private List<String> getComments(AnnotatedElement element) {
@ -27,6 +28,15 @@ final class Comments {
.collect(toMap(Field::getName, this::getComments));
}
private void addVersionComments(Class<?> cls) {
final Version version = Reflect.getVersion(cls);
if (version != null) {
final String vfn = version.fieldName();
final List<String> vfc = Arrays.asList(version.fieldComments());
fieldComments.put(vfn, vfc);
}
}
private boolean hasCommentAnnotation(AnnotatedElement element) {
return element.isAnnotationPresent(Comment.class);
}

@ -0,0 +1,7 @@
package de.exlll.configlib;
public final class ConfigException extends RuntimeException {
public ConfigException(String message) {
super(message);
}
}

@ -8,7 +8,6 @@ import org.yaml.snakeyaml.parser.ParserException;
import org.yaml.snakeyaml.representer.Representer;
import org.yaml.snakeyaml.resolver.Resolver;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
@ -21,9 +20,9 @@ public abstract class Configuration {
private final CommentAdder adder;
/**
* Creates a new {@code Configuration} instance.
* Constructs a new {@code Configuration} instance.
* <p>
* You can use {@link java.io.File#toPath()} to obtain a {@link Path} object
* You can use {@link java.io.File#toPath()} get a {@link Path} object
* from a {@link java.io.File}.
*
* @param configPath location of the configuration file
@ -39,49 +38,107 @@ public abstract class Configuration {
}
/**
* Loads the configuration file from the specified {@code Path} and updates this attribute values.
* Loads {@code this} configuration from a configuration file. The file is
* located at the path pointed to by the {@code Path} object used to create
* {@code this} instance.
* <p>
* The values of the fields of this instance are updated as follows:<br>
* For each non-{@code final}, non-{@code static} and non-{@code transient}
* field of {@code this} configuration instance: <br>
* - If the field's value is null, throw a {@code NullPointerException} <br>
* - If the file contains the field's name, update the field's value with
* the value from the file. Otherwise, keep the default value. <br>
* This algorithm is applied recursively for any non-default field.
*
* @throws ClassCastException if parsed Object is not a {@code Map}
* @throws IOException if an I/O error occurs when loading the configuration file.
* @throws ParserException if invalid YAML
* @throws ClassCastException if parsed Object is not a {@code Map}
* @throws IOException if an I/O error occurs when loading the configuration file
* @throws NullPointerException if a value of a field of this instance is null
* @throws ParserException if invalid YAML
*/
public final void load() throws IOException {
String yaml = ConfigReader.read(configPath);
Map<String, Object> deserializedMap = serializer.deserialize(yaml);
Map<String, Object> deserializedMap = readAndDeserialize();
FieldMapper.instanceFromMap(this, deserializedMap);
postLoadHook();
}
private Map<String, Object> readAndDeserialize() throws IOException {
String yaml = ConfigReader.read(configPath);
return serializer.deserialize(yaml);
}
/**
* Saves this instance and its {@code @Comment} annotations to a configuration file at the specified {@code Path}.
* Saves the comments of {@code this} class as well as the names,
* values and comments of its non-{@code final}, non-{@code static}
* and non-{@code transient} fields to a configuration file. If this
* class uses versioning, the current version is saved, too.
* <p>
* Fields which are {@code final}, {@code static} or {@code transient} are not saved.
* The file used to save this configuration is located at the path pointed
* to by the {@code Path} object used to create {@code this} instance.
* <p>
* If the file exists, it is overridden; otherwise, it is created.
* The default algorithm used to save {@code this} configuration to a file
* is as follows:<br>
* <ol>
* <li>If the file doesn't exist, it is created.</li>
* <li>For each non-{@code final}, non-{@code static} and non-{@code transient}
* field of {@code this} configuration instance:
* <ul>
* <li>If the file doesn't contain the field's name, the field's name and
* value are added. Otherwise, the value is simply updated.</li>
* </ul>
* </li>
* <li>If the file contains field names that don't match any name of a field
* of this class, the file's field names together with their values are
* removed from the file.</li>
* <li>(only with versioning) The current version is updated.</li>
* </ol>
* The default behavior can be overridden using <i>versioning</i>.
*
* @throws IOException if an I/O error occurs when saving the configuration file.
* @throws ParserException if invalid YAML
* @throws ConfigException if a name clash between a field name and the version
* field name occurs (can only happen if versioning is used)
* @throws NoSuchFileException if the old version contains illegal file path characters
* @throws IOException if an I/O error occurs when saving the configuration file
* @throws ParserException if invalid YAML
*/
public final void save() throws IOException {
createParentDirectories();
Map<String, Object> map = FieldMapper.instanceToMap(this);
version(map);
String serializedMap = serializer.serialize(map);
ConfigWriter.write(configPath, adder.addComments(serializedMap));
}
private void version(Map<String, Object> map) throws IOException {
final Version version = Reflect.getVersion(getClass());
if (version == null) {
return;
}
final String vfn = version.fieldName();
if (map.containsKey(vfn)) {
String msg = "Problem: Configuration '" + this + "' cannot be " +
"saved because one its fields has the same name as the " +
"version field: '" + vfn + "'.\nSolution: Rename the " +
"field or use a different version field name.";
throw new ConfigException(msg);
}
map.put(vfn, version.version());
version.updateStrategy().update(this, version);
}
private void createParentDirectories() throws IOException {
Files.createDirectories(configPath.getParent());
}
/**
* Loads and saves the configuration file.
* Loads and saves {@code this} configuration.
* <p>
* This method first calls {@link #load()} and then {@link #save()}.
*
* @throws ClassCastException if parsed Object is not a {@code Map}
* @throws IOException if an I/O error occurs when loading or saving the configuration file.
* @throws ParserException if invalid YAML
* @throws ClassCastException if parsed Object is not a {@code Map}
* @throws IOException if an I/O error occurs when loading or saving the configuration file
* @throws NullPointerException if a value of a field of this instance is null
* @throws ParserException if invalid YAML
* @see #load()
* @see #save()
*/
@ -89,60 +146,70 @@ public abstract class Configuration {
try {
load();
save();
} catch (NoSuchFileException | FileNotFoundException e) {
} catch (NoSuchFileException e) {
postLoadHook();
save();
}
}
/**
* Protected method invoked after all fields have been loaded.
* <p>
* The default implementation of this method does nothing.
* Protected method invoked after all fields have successfully been loaded.
* <p>
* Subclasses may override this method in order to execute some action
* after all fields have been loaded.
* The default implementation of this method does nothing. Subclasses may
* override this method in order to execute some action after all fields
* have successfully been loaded.
*/
protected void postLoadHook() {
}
protected void postLoadHook() {}
/**
* Creates a {@code BaseConstructor} which is used to configure a {@link Yaml} object.
* Returns a {@link BaseConstructor} which is used to configure a
* {@link Yaml} object.
* <p>
* Override this method to change the way the {@code Yaml} object is created.
* <p>
* This method may not return null.
*
* @return new {@code BaseConstructor}
* @return a {@code BaseConstructor} object
* @see org.yaml.snakeyaml.constructor.BaseConstructor
* @see #createRepresenter()
* @see #createDumperOptions()
* @see #createResolver()
*/
protected BaseConstructor createConstructor() {
return new Constructor();
}
/**
* Creates a {@code Representer} which is used to configure a {@link Yaml} object.
* Returns a {@link Representer} which is used to configure a {@link Yaml}
* object.
* <p>
* Override this method to change the way the {@code Yaml} object is created.
* <p>
* This method may not return null.
*
* @return new {@code Representer}
* @return a {@code Representer} object
* @see org.yaml.snakeyaml.representer.Representer
* @see #createConstructor()
* @see #createDumperOptions()
* @see #createResolver()
*/
protected Representer createRepresenter() {
return new Representer();
}
/**
* Creates a {@code DumperOptions} object which is used to configure a {@link Yaml} object.
* Returns a {@link DumperOptions} object which is used to configure a
* {@link Yaml} object.
* <p>
* Override this method to change the way the {@code Yaml} object is created.
* <p>
* This method may not return null.
*
* @return new {@code DumperOptions}
* @return a {@code DumperOptions} object
* @see org.yaml.snakeyaml.DumperOptions
* @see #createConstructor()
* @see #createRepresenter()
* @see #createResolver()
*/
protected DumperOptions createDumperOptions() {
DumperOptions options = new DumperOptions();
@ -152,16 +219,38 @@ public abstract class Configuration {
}
/**
* Creates a {@code Resolver} which is used to configure a {@link Yaml} object.
* Returns a {@link Resolver} which is used to configure a {@link Yaml} object.
* <p>
* Override this method to change the way the {@code Yaml} object is created.
* <p>
* This method may not return null.
*
* @return new {@code Resolver}
* @return a {@code Resolver} object
* @see org.yaml.snakeyaml.resolver.Resolver
* @see #createConstructor()
* @see #createRepresenter()
* @see #createDumperOptions()
*/
protected Resolver createResolver() {
return new Resolver();
}
final String currentFileVersion() throws IOException {
final Version version = Reflect.getVersion(getClass());
return (version == null) ? null : readCurrentFileVersion(version);
}
private String readCurrentFileVersion(Version version) throws IOException {
try {
final Map<String, Object> map = readAndDeserialize();
return (String) map.get(version.fieldName());
} catch (NoSuchFileException ignored) {
/* there is no file version if the file doesn't exist */
return null;
}
}
final Path getPath() {
return configPath;
}
}

@ -105,4 +105,8 @@ enum Reflect {
checkType(entry.getValue(), valueClass);
}
}
static Version getVersion(Class<?> cls) {
return cls.getAnnotation(Version.class);
}
}

@ -0,0 +1,50 @@
package de.exlll.configlib;
import java.io.IOException;
import java.nio.file.*;
/**
* The constants of this enumerated type describe strategies used to update
* different versions of configuration files. The strategies are only applied
* if a version change is detected.
*/
public enum UpdateStrategy {
/**
* Updates the configuration file using the default strategy described at
* {@link Configuration#save()}.
*/
DEFAULT {
@Override
void update(Configuration config, Version version) {}
},
/**
* Updates the configuration file using the {@link #DEFAULT} strategy.
* Before the configuration is updated a copy of its current version is
* saved. If the configuration uses versioning for the first time, the
* copy is named "&lt;filename&gt;-old". Otherwise, the old version is
* appended to the file name: "&lt;filename&gt;-v&lt;old version&gt;".
*
* @see UpdateStrategy#DEFAULT
*/
DEFAULT_RENAME {
@Override
void update(Configuration config, Version version) throws IOException {
final Path path = config.getPath();
if (!Files.exists(path)) {
return;
}
final String fileVersion = config.currentFileVersion();
if (!version.version().equals(fileVersion)) {
final FileSystem fs = path.getFileSystem();
final String v = (fileVersion == null) ? "-old" : "-v" + fileVersion;
final String fn = path.toString() + v;
Files.move(path, fs.getPath(fn), StandardCopyOption.REPLACE_EXISTING);
}
}
};
abstract void update(Configuration config, Version version)
throws IOException;
}

@ -0,0 +1,45 @@
package de.exlll.configlib;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Indicates that the annotated {@link Configuration} has a version.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Version {
/**
* Returns the current version of a configuration.
*
* @return current configuration version
*/
String version() default "1.0.0";
/**
* Returns the name of the version field.
*
* @return name of the version field
*/
String fieldName() default "version";
/**
* Returns the comments describing the version field.
*
* @return comments describing the version field
*/
String[] fieldComments() default {
"", /* empty line */
"The version of this configuration - DO NOT CHANGE!"
};
/**
* Returns an {@link UpdateStrategy} describing the actions applied to different
* versions of a configuration file when a version change is detected.
*
* @return {@code UpdateStrategy} applied to a configuration file
*/
UpdateStrategy updateStrategy() default UpdateStrategy.DEFAULT;
}

@ -16,6 +16,7 @@ import org.junit.runners.Suite;
FilteredFieldsTest.class,
ReflectTest.class,
TypeConverterTest.class,
VersionTest.class,
YamlSerializerTest.class
})
public class ConfigLibTestSuite {

@ -11,6 +11,7 @@ import org.junit.Test;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
@ -34,7 +35,7 @@ public class ConfigurationTest {
public void saveCreatesParentDirectories() throws Exception {
TestConfiguration cfg = new TestConfiguration(configPath);
assertThat(Files.exists(configPath.getParent()), is(false));
cfg.save();
assertThat(Files.exists(configPath.getParent()), is(true));
}
@ -69,4 +70,24 @@ public class ConfigurationTest {
cfg.save();
cfg.load();
}
@Test
public void loadExecutesPostLoadHook() throws Exception {
AtomicInteger integer = new AtomicInteger();
Configuration cfg = new TestConfiguration(configPath, integer::incrementAndGet);
cfg.save();
assertThat(integer.get(), is(0));
cfg.load();
assertThat(integer.get(), is(1));
}
@Test
public void loadAndSaveExecutesPostLoadHook1() throws Exception {
AtomicInteger integer = new AtomicInteger();
Configuration cfg = new TestConfiguration(configPath, integer::incrementAndGet);
cfg.loadAndSave();
assertThat(integer.get(), is(1));
}
}

@ -11,6 +11,8 @@ import java.util.Map;
import java.util.Set;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertThat;
public class ReflectTest {
@ -140,11 +142,18 @@ public class ReflectTest {
TestClass t = (TestClass) Reflect.newInstance(TestClass.class);
}
@Test
public void getVersion() throws Exception {
assertThat(Reflect.getVersion(cls1), notNullValue());
assertThat(Reflect.getVersion(cls2), nullValue());
}
private static final class NotDefaultConstructor {
public NotDefaultConstructor(String a) {
}
}
@Version(version = "1.2.3")
private static final class TestClass {
private String s = "s";
}

@ -8,6 +8,7 @@ import java.util.*;
"This comment is applied to a class."
})
final class TestConfiguration extends Configuration {
private transient Runnable postLoadAction;
@Comment({
"This comment is applied to a field.",
"It has more than 1 line."
@ -19,6 +20,7 @@ final class TestConfiguration extends Configuration {
private List<String> allowedIps = new ArrayList<>();
private Map<String, Integer> intsByStrings = new HashMap<>();
private Map<String, List<String>> stringListsByString = new HashMap<>();
private Credentials credentials = new Credentials();
public TestConfiguration(Path path) {
@ -36,6 +38,11 @@ final class TestConfiguration extends Configuration {
stringListsByString.put("za", Arrays.asList("z1", "z2"));
}
public TestConfiguration(Path path, Runnable postLoadAction) {
this(path);
this.postLoadAction = postLoadAction;
}
public int getPort() {
return port;
}
@ -128,4 +135,11 @@ final class TestConfiguration extends Configuration {
"credentials:\n" +
" username: root\n" +
" password: '1234'\n";
@Override
protected void postLoadHook() {
if (postLoadAction != null) {
postLoadAction.run();
}
}
}

@ -0,0 +1,118 @@
package de.exlll.configlib;
import com.google.common.jimfs.Jimfs;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertThat;
public class VersionTest {
private static final String CONFIG_AS_STRING =
"# class\n\n# field\nk: 0\n\n# c1\n";
private static final String VERSION_CONFIG_AS_STRING =
"# class\n\n# field\nk: 0\n\n# c1\nv: 1.2.3-alpha\n";
private static final String OLD_VERSION_CONFIG_AS_STRING =
"# class\n\n# field\nk: 0\n\n# c1\nv: 1.2.2-alpha\n";
@Rule
public ExpectedException exception = ExpectedException.none();
private FileSystem fileSystem;
private Path configPath;
@Before
public void setUp() throws Exception {
fileSystem = Jimfs.newFileSystem();
configPath = fileSystem.getPath("/a/b/config.yml");
}
@After
public void tearDown() throws Exception {
fileSystem.close();
}
@Test
public void versionSaved() throws Exception {
new VersionConfiguration(configPath).save();
assertThat(ConfigReader.read(configPath), is(VERSION_CONFIG_AS_STRING));
}
@Test
public void saveThrowsExceptionIfVersionNameClash() throws Exception {
exception.expect(ConfigException.class);
exception.expectMessage("Solution: Rename the field or use a " +
"different version field name.");
new VersionConfigurationWithVersionField(configPath).save();
}
@Test
public void currentFileVersionReturnsEmptyOptionalIfFileDoesntExist() throws Exception {
Configuration cfg = new VersionConfiguration(configPath);
assertThat(cfg.currentFileVersion(), nullValue());
}
@Test
public void currentFileVersionReturnsOptionalWithVersion() throws Exception {
Configuration cfg = new VersionConfiguration(configPath);
cfg.save();
assertThat(cfg.currentFileVersion(), is("1.2.3-alpha"));
}
@Test
public void updateRenameApplied() throws Exception {
Files.createDirectories(configPath.getParent());
ConfigWriter.write(configPath, CONFIG_AS_STRING);
final Path oldPath = fileSystem.getPath(configPath.toString() + "-old");
new VersionConfiguration(configPath).save();
assertThat(ConfigReader.read(configPath), is(VERSION_CONFIG_AS_STRING));
assertThat(ConfigReader.read(oldPath), is(CONFIG_AS_STRING));
}
@Test
public void updateRenameAppendsOldVersion() throws Exception {
final Path oldPath = fileSystem.getPath(configPath.toString() + "-v1.2.2-alpha");
Files.createDirectories(configPath.getParent());
ConfigWriter.write(configPath, OLD_VERSION_CONFIG_AS_STRING);
ConfigWriter.write(oldPath, "123");
new VersionConfiguration(configPath).save();
assertThat(ConfigReader.read(configPath), is(VERSION_CONFIG_AS_STRING));
assertThat(ConfigReader.read(oldPath), is(OLD_VERSION_CONFIG_AS_STRING));
}
@Comment("class")
@Version(version = "1.2.3-alpha",
fieldName = "v",
fieldComments = {"", "c1"},
updateStrategy = UpdateStrategy.DEFAULT_RENAME)
private static final class VersionConfiguration extends Configuration {
@Comment("field")
private int k;
protected VersionConfiguration(Path configPath) {
super(configPath);
}
}
@Version
private static final class VersionConfigurationWithVersionField
extends Configuration {
private int version;
protected VersionConfigurationWithVersionField(Path configPath) {
super(configPath);
}
}
}

@ -9,6 +9,7 @@ names and values, creating the configuration file and its parent directories if
- option to add explanatory comments by adding annotations to the class or its fields
- option to exclude fields by making them final, static or transient
- option to change the style of the configuration file
- option to version configuration files and change the way updates are applied
## General information
#### What can be serialized?
@ -55,6 +56,9 @@ been loaded.
By using the `@Comment` annotation, you can add comments to your configuration file. The
annotation can be applied to classes or fields. Each `String` of the passed array is
written into a new line.
#### Versioning
Use the `@Version` annotation to enable versioning. Versioning lets you change the way
how configuration files are updated when a version change is detected.
#### Custom configuration style
You can change the style of the configuration file by overriding the protected `create...` methods
of your configuration class. Overriding these methods effectively changes the behavior of the
@ -82,6 +86,7 @@ import de.exlll.configlib.Configuration;
"This is a multiline comment.",
"It describes what the configuration is about."
})
@Version(version = "1.2.3")
public final class DatabaseConfig extends Configuration {
/* ignored fields */
private final String ignored1 = ""; // ignored because final
@ -135,14 +140,14 @@ public class ExamplePlugin extends JavaPlugin {
<dependency>
<groupId>de.exlll</groupId>
<artifactId>configlib-bukkit</artifactId>
<version>1.3.2</version>
<version>1.4.0</version>
</dependency>
<!-- for Bungee plugins -->
<dependency>
<groupId>de.exlll</groupId>
<artifactId>configlib-bungee</artifactId>
<version>1.3.2</version>
<version>1.4.0</version>
</dependency>
```
#### Gradle
@ -154,10 +159,10 @@ repositories {
}
dependencies {
// for Bukkit plugins
compile group: 'de.exlll', name: 'configlib-bukkit', version: '1.3.2'
compile group: 'de.exlll', name: 'configlib-bukkit', version: '1.4.0'
// for Bungee plugins
compile group: 'de.exlll', name: 'configlib-bungee', version: '1.3.2'
compile group: 'de.exlll', name: 'configlib-bungee', version: '1.4.0'
}
```
Additionally, you either have to import the Bukkit or BungeeCord API

@ -1,6 +1,6 @@
allprojects {
group 'de.exlll'
version '1.3.2'
version '1.4.0'
}
subprojects {
apply plugin: 'java'

@ -36,6 +36,9 @@ channels:
owner: User2
members:
- User1
# Current version - DON'T TOUCH!
my_version: '1.2.3-alpha'
```
### 1. Create a configuration
Create a class which extends `de.exlll.configlib.Configuration`.
@ -118,8 +121,7 @@ public final class WebchatConfig extends Configuration {
public WebchatConfig(Path configPath) {
super(configPath);
}
/* other classes and methods */
// ... remainder unchanged
}
```
### 4. Add comments
@ -201,7 +203,27 @@ public final class WebchatConfig extends Configuration {
}
```
### 6. Create an instance of your configuration
### 6. Add the version
```java
import de.exlll.configlib.Configuration;
import de.exlll.configlib.Version;
import java.nio.file.Path;
@Version(
version = "1.2.3-alpha",
fieldName = "my_version",
fieldComments = {
"" /* empty line */,
"Current version - DON'T TOUCH!"
}
)
public final class WebchatConfig extends Configuration {
// ... remainder unchanged
}
```
### 7. Create an instance of your configuration
Create a `java.nio.file.Path` object and pass it to the configuration constructor.
```java
/* imports */

Loading…
Cancel
Save