Skip to content

Add better support for updating configurations #26

@Exlll

Description

@Exlll

Goal: Give users more control over how configurations are updated.

The load process currently has the following properties over which developers have little control:

  • If a configuration file contains a node that does not correspond to some configuration element, this node is simply discarded.
  • If a node that corresponds to some configuration element is missing in the configuration file, the default value of that configuration element is used.
  • If a node contains an invalid value or a value that is not supported any longer (e.g. some enum constant), there is no way to replace that value during the loading process.

In many cases, the current behavior is not a problem as users can manually update a configuration file to a newer version. However, it would be convenient for users if this library allowed developers to implement automatic updates for their configurations.

A simple example is the renaming of configuration elements: Currently, if a configuration element (e.g. a class field) of some configuration type is renamed without manually updating the configuration file prior to loading a configuration of that configuration type, the information that was stored in the configuration file for that element is simply lost, as there is no way for developers to access this information during loading.

Implementation-wise, the easiest way to change all three properties listed above is to give developers full access to the Map instance returned by the YAML parser and let them do whatever they want with it. While this solution is easy to implement, it has two major drawbacks: First, accessing (deeply) nested values involves quite a lot of casting. Second, map keys used by developers might not have a mapping even though they appear to have one, possibly causing errors at runtime or confusion at development. The second problem is mostly caused by number types: Because the map instance returned by the YAML parser would, once this feature is implemented, only contain valid target types as map keys (in the case of numbers Long, and Double), a call like, for example map.get(1) would always return null, as 1 is of type int and would, therefore, never have a mapping.

A proper solution would be to transform the Map instance returned by the YAML parser to some internal tree-like or node-based representation and to then give developers access to this representation only. While this might result a clean API that abstracts away the underlying map and remedies the problems mentioned above, implementing such an API requires a lot of effort as major parts of the current implementation of this library would have to be rewritten. Also, the transformation of map instances to such a node-based representation might add a lot of computation and memory overhead at runtime.

A middle way can be reached by providing a small wrapper around the map instance. That wrapper can provide an API for accessing nested values without the need for casting and also support common operations like put or rename for adding or moving values around, respectively.

For example, this new Update-API could introduce a MapView interface whose implementation serves as a wrapper for the Map instance returned by the YAML parser, and a MapViewActions class that contains factories for common operations. These factories could return Consumers which allows chaining them together via the andThen method of the Consumer interface (see second example).

public interface MapView {
    interface Key {}
    static Key key(String... parts) { /* return key */ }
    
    Map<?, ?> getDelegate();
    void put(Key key, Object value);
    Object remove(Key key);
    Object get(Key key);
    Boolean getBoolean(Key key);
    Long getLong(Key key);
    Double getDouble(Key key);
    String getString(Key key);
    List<?> getList(Key key);
    Map<?, ?> getMap(Key key);
}

public final class MapViewActions {
    public static Consumer<MapView> put(Key key, Object value) {
        return view -> { /* do stuff */ };
    }
    public static Consumer<MapView> remove(Key key) {
        return view -> { /* do stuff */ };
    }
    public static Consumer<MapView> move(Key fromKey, Key toKey) {
        return view -> { /* do stuff */ };
    }
    public static Consumer<MapView> copy(Key fromKey, Key toKey) {
        return view -> { /* do stuff */ };
    }
}

Using these two classes, developers could write a custom Consumer that serves as an update function for a given configuration. Once written, that Consumer could then be passed to this library via a ConfigurationProperties object. For example, for a configuration type that has a configuration element (i.e. a class field or a record component) with name version, the update function could use that field to bring some configuration file up-to-date to the newest version, as shown below.

public final class Example {
    private static final String CURRENT_VERSION = "1.2.0";
    
    public static void main(String[] args) {
        final Consumer<MapView> updater = view -> {
            final Key versionKey = key("version");

            String version = view.getString(versionKey);
            while (!CURRENT_VERSION.equals(version)) {
                if (version == null) throw new RuntimeException("Missing version");
                switch (version) {
                    case "1.0.0" -> update_v1_0_0_to_v1_1_0().accept(view);
                    case "1.1.0" -> update_v1_1_0_to_v1_2_0().accept(view);
                    default -> throw new RuntimeException("Invalid version: " + version);
                }
                version = view.getString(versionKey);
            }
        };

        final ConfigurationProperties properties = ConfigurationProperties.newBuilder()
                .setUpdater(updater)
                .build();

        // use this properties object when loading the configuration...
    }
    
    private static Consumer<MapView> update_v1_0_0_to_v1_1_0() {
        return MapViewActions.put(key("a", "b"), "SOME_VALUE")
                .andThen(copy(key("a", "b"), key("a", "c")))
                .andThen(move(key("a", "b"), key("a", "d")))
                .andThen(remove(key("a", "c")))
                .andThen(put(key("version"), "1.1.0"));
    }

    private static Consumer<MapView> update_v1_1_0_to_v1_2_0() {
        return MapViewActions.put(key("version"), "1.2.0");
    }
}

An API to make version updates easier could additionally be added as part of this new Update-API or in a future release.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions