Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add better support for updating configurations #26

Open
Exlll opened this issue Nov 7, 2023 · 2 comments
Open

Add better support for updating configurations #26

Exlll opened this issue Nov 7, 2023 · 2 comments
Assignees

Comments

@Exlll
Copy link
Owner

Exlll commented Nov 7, 2023

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.

@Exlll Exlll self-assigned this Nov 7, 2023
@Emibergo02
Copy link

Emibergo02 commented Nov 30, 2023

Very good. Do you have an ETA?

@Exlll
Copy link
Owner Author

Exlll commented Nov 30, 2023

@Emibergo02 I don't, sorry. Other than writing down my idea and thinking about a suitable API, I haven't started working on this feature yet. I try to spend my time only on things that are requested, but since already two of you would like such an API, I can start working on it these days.

As I'm not sure whether what I've come up with will indeed result in a developer-friendly API, I'd be happy I you guys could provide feedback along the way!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants