Copy strategies

Copy strategies control how copyFrom applies a donor recipe to the current mutable Builder. This is mainly used for Templates, but can also merge multiple recipe sources in a Helm-like way. In the descriptions below, target always means the Builder being configured; a completed DSL Object is never mutated.

Copy source protocol

The donor's identity determines whether deferred recipe actions are replayed:

Each replay clones and schedules its actions against the recipient Builder. Already executed live-Builder actions are not copied, and action order is preserved. Fresh owned children run one ordinary lifecycle and receive their final ownership and model paths when the recipient graph is materialized.

When copying an object to another, there are three distinct types of strategies: single object, collections and maps. The strategy can be configured on a class for all matching fields at once, or on a per-field basis.

Fields are marked with one of the following annotations, depending on the type:

Also, fields, the class or the package can be annotated with @Overwrite to apply the strategy to all fields in the class or package.

Single Object

Single object strategies handle how a single object field is copied from a donor recipe. The behaviour differs for DSL Objects and ordinary values. Ordinary values are retained, so donor and completed model share the same value instance. A DSL Object donor is treated as a recipe: its state is rehydrated into a fresh child Builder and copied using that DSL Object type's overwrite strategies. The completed donor object itself is never adopted as owned composition.

The strategy is determined by checking in the following order and using the first match:

The strategy can be one of the following:

(See: CopyStrategiesDocumentaryTest#'merges a nested service configuration from a template'.)

given: // Schema
@DSL
class Endpoint {
    String host
    Integer port
}

@DSL
class Service {
    @Overwrite.Single(OverwriteStrategy.Single.MERGE)
    Endpoint endpoint
}

when: // Model
def baseline = Service.Create.Template.With {
    endpoint { host 'catalog.example.test' }
}
def service = Service.Create.With {
    endpoint { port 8443 }
    copyFrom baseline
}

then:
assert service.endpoint.host == 'catalog.example.test'
assert service.endpoint.port == 8443

INHERIT

Not a strategy in itself, should no be used directly.

REPLACE

If the donor's field is not null, the field of the target is completely replaced by the donor's field (or a copy of the donor's field in case of a DSL object).

ALWAYS_REPLACE

The target's field is replaced with the donor's field, even if the donor's field is null. This can be used to clear a field with a default value using a template. This strategy should be used only sparingly, and almost always only on specific fields as opposed to classes or packages.

SET_IF_NULL

The target field is on set if it was null before. This can be used to set default values in the target object if they are not already set. This can be especially useful outside of templates, for example in the Default Phase.

MERGE

Merge is the default strategy. For non-DSL fields, this behaves exactly as REPLACE. For DSL fields, the donor's value is copied into the targets value (or a newly created object if the target's value is null). The nested fields are copied over using the overwrite strategies of the target's field's type.

Collections

Collection strategies handle how a collection field is copied from a donor recipe. The donor collection is never linked into the Builder. The Builder's mutable construction collection is reused and may be cleared depending on the strategy; materialization later publishes an independent read-only snapshot.

If the collection contains DSL Objects, each donor value is rehydrated into a fresh child Builder. Simple Values are retained. Collections of collections are not supported schema declarations.

The strategy is determined in exactly the same way as for single object fields, but using the @Overwrite.Collection annotation instead.

The default strategy is REPLACE.

The strategy can be one of the following:

(See: CopyStrategiesDocumentaryTest#'adds template roles to a service configuration'.)

given: // Schema
@DSL
class Service {
    @Overwrite.Collection(OverwriteStrategy.Collection.ADD)
    List<String> roles
}

when: // Model
def baseline = Service.Create.Template.With {
    roles 'observer'
}
def service = Service.Create.With {
    roles 'operator'
    copyFrom baseline
}

then:
assert service.roles == ['operator', 'observer']

INHERIT

Not a strategy in itself, should no be used directly.

ADD

The donor's collection is added to the target's collection. The order is determined by the collection type (e.g. List preserves order, Set does not).

REPLACE

The target's collection is replaced by the donor's collection's elements, but only if the donor's collection is not empty.

SET_IF_EMPTY

The donor's collection is added to the target's collection, but only if the target's collection is empty.

ALWAYS_REPLACE

The target's collection is replaced by the donor's collection's elements, even if the donor's collection is empty. This can be used to clear a collection with a default value using a template. This strategy should be used only sparingly, and almost always only on specific fields as opposed to classes or packages.

Note that if the collection field of the donor is null instead of an empty collection, the target's collection is always left untouched. Also note that in order to set a collection to null, the setter syntax must be used instead of the usual methods.

MyObject.Create.With {
    elements = null  // "element null" would add null to the collection 
}

Maps

Map strategies handle how a map field is copied from a donor recipe. The donor map is never linked into the Builder. The Builder's mutable map is reused and may be cleared depending on the strategy; materialization later publishes an independent read-only snapshot. Maps whose values are maps or collections are not supported schema declarations.

As with collections, DSL Object values are rehydrated into fresh child Builders and Simple Values are retained.

The strategies are as follows:

(See: CopyStrategiesDocumentaryTest#'merges environment map values from a template'.)

given: // Schema
@DSL
class Environment {
    @Key String name
    String region
    Integer replicas
}

@DSL
class Deployment {
    @Overwrite.Map(OverwriteStrategy.Map.MERGE_VALUES)
    Map<String, Environment> environments
}

when: // Model
def baseline = Deployment.Create.Template.With {
    environment('production') { region 'eu-central' }
}
def deployment = Deployment.Create.With {
    environment('production') { replicas 3 }
    copyFrom baseline
}

then:
assert deployment.environments.production.region == 'eu-central'
assert deployment.environments.production.replicas == 3

INHERIT

Not a strategy in itself, should no be used directly.

FULL_REPLACE

The target's map is replaced by the donor's map (if not empty). This is the default strategy.

ALWAYS_REPLACE

The target's map is replaced by the donor's map, even if the donor's map is empty. This can be used to clear a map with a default value using a template. This strategy should be used only sparingly, and almost always only on specific fields as opposed to classes or packages.

SET_IF_EMPTY

The target's map is replaced by the donor's map, but only if the target's map is empty.

MERGE_KEYS

The donor's map is added to the target's, replacing existing values with matching keys.

MERGE_VALUES

The donor's map is merged with the target's. New keys are added, but the values of existing keys are merged with the target's values. The merge strategy is determined by the target's field's type's overwrite strategy.

For non-DSL values, this behaves exactly as 'MERGE_KEYS', i.e. the values are replaced.

ADD_MISSING

All entries in the donor's map whose keys are not present in the target's map are added to the target's map.

Nested Annotations

Nested Annotations can be used to give meaningful names to a couple of strategies. For example, the HelmOverwrite annotation is a nested annotation that sets the strategy to MERGE for single object fields, ALWAYS_REPLACE for collections and MERGE_VALUES for maps, resembling the way helm merges value files.

(See: CopyStrategiesDocumentaryTest#'applies the packaged Helm copy policy to a deployment'.)

import com.blackbuild.klum.ast.copy.HelmOverwrite

given: // Schema
@DSL
class Service {
    @Key String name
    String host
    String port
}

@HelmOverwrite
@DSL
class Deployment {
    String image
    List<String> arguments
    Map<String, Service> services
}

when: // Model
def baseline = Deployment.Create.Template.With {
    image 'catalog:2.0'
    arguments = []
    service('web') { port '8443' }
    service('metrics') { host 'metrics.example.test' }
}
def deployment = Deployment.Create.With {
    image 'catalog:1.0'
    arguments '--verbose'
    service('web') { host 'catalog.example.test' }
    copyFrom baseline
}

then:
assert deployment.image == 'catalog:2.0'
assert deployment.arguments == []
assert deployment.services.web.host == 'catalog.example.test'
assert deployment.services.web.port == '8443'
assert deployment.services.metrics.host == 'metrics.example.test'

It is defined as follows:

@Target({ElementType.FIELD, ElementType.TYPE, ElementType.PACKAGE})
@Retention(RetentionPolicy.RUNTIME)
@Overwrite(
        singles = @Overwrite.Single(OverwriteStrategy.Single.MERGE),
        collections = @Overwrite.Collection(OverwriteStrategy.Collection.ALWAYS_REPLACE),
        maps = @Overwrite.Map(OverwriteStrategy.Map.MERGE_VALUES)
)
public @interface HelmOverwrite {
}

Note that only one level of nesting is supported, i.e. you cannot nest annotations inside nested annotations.

Missing field handling

The @Overwrite annotation as well as nested annotations can contain an additional member named missing of type OverwriteStrategy.Missing, which controls the handling of fields in the donor that are not present in the target object. This can either be FAIL (the default) or IGNORE.

Note that Overwrite.Missing itself can only be used on annotation types. Since the missing-strategy is checked on the target class, it is resolved from the target class (including its @Overwrite, package and superclasses), not from a field.