Basics

KlumAST consists of a number of Annotations:

@DSL

DSL is used to designate a DSL/Model object, which is enriched using the AST transformation.

The DSL annotation leads to the creation of a couple of useful methods.

Start a Schema source file with the canonical KlumAST annotation imports. These imports apply to ordinary Schema annotations regardless of whether the project later adopts a separate Domain API.

import com.blackbuild.klum.ast.DSL
import com.blackbuild.klum.ast.Key
import com.blackbuild.klum.ast.Validate

Factory construction

Each instantiable DSL class gets a static field Create of either a subclass of KlumFactory.Keyed or KlumFactory.Unkeyed, which provides methods to create instances of the class; abstract classed get an implementation of KlumFactory instead.

given: // Schema
import com.blackbuild.klum.ast.DSL
import com.blackbuild.klum.ast.Key
import com.blackbuild.klum.ast.Validate

@DSL
class Config {
}

@DSL
class ConfigWithKey {
    @Key String name
}

allows to create instances with the following calls:

when: // Model
Config.Create.One()
Config.Create.With(a: 1, b: 2)
Config.Create.With(a: 1, b: 2) { c 3 }
Config.Create.With { c 3 }

ConfigWithKey.Create.One('Dieter')
ConfigWithKey.Create.With('Dieter', a: 1, b: 2)
ConfigWithKey.Create.With('Dieter', a: 1, b: 2) { c 3 }
ConfigWithKey.Create.With('Dieter') { c 3 }

The optional closure to the With method configures the generated Builder. The One method is a shortcut for With without any given values, which makes a nicer syntax (Config.Create.With() seems a bit strange, Config.Create.One() looks better). The factory materializes the completed DSL Object graph before returning it.

If the class contains an static inner class named 'Factory' of the appropriate type or the member factory points to such a class, this class is used as a base for the generated factory instead. This allows adding additional methods to the factory.

Create.With supports named parameters, allowing values to be set concisely. Every map element of the method call is converted in a setter call (actually, any method named like the key with a single argument will be called):

when: // Model
Config.Create.With {
    name "Dieter"
    age 15
}

Could also be written as:

when: // Model
Config.Create.With(name: 'Dieter', age: 15)

Of course, named parameters and regular calls inside the closure can be combined ad lib.

For example, a keyed deployment and its owned service can be configured together:

given: // Schema
@DSL
class Deployment {
    @Key String name
    String environment
    Service service
}

@DSL
class Service {
    String image
}

when: // Model
def deployment = Deployment.Create.With('catalog') {
    environment 'production'
    service {
        image 'catalog:1.0'
    }
}

deployment is the completed DSL Object.

(See: FactoryConstructionTest#'builds a completed deployment configuration with Create.With'.)

There are also a couple of Convenience Factories to load a model into client code.

Lifecycle Methods

Lifecycle methods are methods annotated with lifecycle annotations. For example, @PostCreate runs after Builder creation (after templates have been applied) and @PostApply runs after explicit Builder configuration. See Model Phases for the complete lifecycle contract.

Other lifecycle methods will be executed in the corresponding phase.

Lifecycle methods must not be private. They are moved to the generated Builder. Mutating lifecycle work through POST_TREE receives Builders; validation receives completed DSL Objects after INSTANTIATE.

copyFrom() Method

Each Builder gets a copyFrom() DSL method. This method copies fields from a DSL Object recipe into the current Builder, excluding key, owner and @Role fields, as well as fields marked FieldType.TRANSIENT or FieldType.IGNORED. Copying is further governed by @Overwrite / the configured OverwriteStrategy. This is done recursively: nested Template composition is rehydrated into fresh Builders. Completed ordinary models are not adopted as owned composition. Copy Strategies describes the available merge behavior.

equals() Method

If not yet present, the equals() method is generated using the default @EqualsAndHashCode ASTTransformations. You can customize it by using the original ASTTransformation.

hashCode()

A barebone hashCode is created, with a constant 0 for non-keyed objects, and the hashcode of the key for keyed objects. While this is correct and works with changing objects after adding them to a HashSet / HashMap, the performance for Sets of non-Keyed objects is severely reduced.

Field setter

Field setter for simple fields

For each Simple Value field, the Builder gets an accessor named like the field and taking the field type as parameter. These methods are available only during factory, Template, or lifecycle configuration. Completed DSL Objects expose read-only accessors.

given: // Schema
@DSL
class Config {
 String name
}

creates the following method:

def name(String value)

Used by:

when: // Model
Config.Create.With {
   name "Hallo"
}

Setter for simple collections

for each simple collection, two/three methods are generated:

given: // Schema
@DSL
class Config {
    List<String> roles
    Map<String, Integer> levels
}

creates the following methods:

def roles(String... values)
def roles(Iterable<String> values)
def role(String value)
def levels(Map levels)
def level(String key, Integer value)

Usage:

when: // Model
Config.Create.With {
    roles "a", "b"
    role "another"
    levels a:5, b:10
    level "high", 8
}

If the collection has no initial value, it is automatically initialized. SortedSet and NavigableSet use natural-order sorted storage by default, as do SortedMap and NavigableMap. Supply an explicit TreeSet or TreeMap initializer when the Schema needs a custom comparator; the completed model keeps that ordering in its read-only collection view.

keyMapping for Simple Maps

Instead of directly providing the key in the adder call, it can also be derived from the value itself. This is done by using the keyMapping attribute of the @Field annotation.

This attribute accepts a closure that gets a single parameter of the value type and must return a value of the key type.

If a keyMapping is set for a simple type, adder methods only have a value parameter (instead of key and value), and the map adder is replaced with a collection adder.

(See: BasicsKeyMappingDocumentaryTest#'derives simple map keys from configured values'.)

given: // Schema
@DSL class Foo {

    @Field(keyMapping = { it.toLowerCase() })
    Map<String, String> values
}

when: // Model
Foo.Create.With {
    value "bla"
    value "BLUB"
    values "bla", "blub"
    values(["bli", "blu"])
} 

Setters and closures for DSL-Object Fields

For each DSL Object composition field, a Builder closure method is generated. If the field is keyed, this method has an additional key parameter. The relationship field holds a child Builder during construction; the closure method returns that Builder so further construction-time configuration can be composed.

given: // Schema
@DSL
class Config {
    UnKeyed unkeyed
    Keyed keyed
}

@DSL
class UnKeyed {
    String name
}

@DSL
class Keyed {
    @Key String name
    String value
}

conceptually creates the following Builder methods (the concrete generated Builder type and location are not public API):

def unkeyed(@DelegatesTo(/* UnKeyed Builder */) Closure closure)
def keyed(String key, @DelegatesTo(/* Keyed Builder */) Closure closure)

Usage:

when: // Model
Config.Create.With {
    unkeyed {
        name "other"
    }
    keyed("klaus") {
        value "a Value"
    }
}

The closure methods return the child Builder during construction:

when: // Model
Config.Create.With {
    def childBuilder = unkeyed {
        name "other"
    }
    childBuilder.name "final"
}

An already completed DSL Object cannot be adopted as owned composition. Existing completed objects are accepted only by FieldType.LINK fields, where they remain aggregation targets and are not re-owned or mutated.

Polymorphic DSL members

To create subclasses of the requested element (the field is of type Element, but we want the value to be of type SubElement), there are several options:

Dynamic Class selection

For non final field types, a polymorphic setter is created that takes the requested type as first parameter:

when: // Model
Config.Create.With {
  main(SubElement) {
    ...
  }
}

This dynamic form remains supported and keeps the subclass in the parent's Builder lifecycle. Because the implementation is supplied as a Class value, static compilation types the configuration closure against the relationship's declared base Builder.

Typed Factory selection

Under static compilation, pass the selected type's generated Create factory instead:

(See: BasicsStaticPolymorphismDocumentaryTest#'selects a polymorphic child with its generated factory under static compilation'.)

when: // Model
Config.Create.With {
  main(SubElement.Create) {
    subElementOnlyProperty 'value'
  }
}

SubElement.Create implements the public generated factory-provider contract, so the method returns SubElement_DSL.Builder<SubElement> and delegates the closure to that exact public Builder. The provider only selects the implementation for this relationship; child creation still belongs to the current parent Construction session and never starts a root lifecycle. The same form is available for collection and map relationships, including named parameters and keys. Use the Class form when selection is intentionally dynamic and the Create form when static Builder precision is needed.

Existing completed values

Starting SubElement.Create.With inside an active parent factory starts an independent lifecycle and cannot produce a newly owned child. Passing SubElement.Create to the parent's relationship method is contextual selection, not a nested factory call, and therefore remains in the parent's lifecycle. If the relationship is aggregation rather than composition, annotate it with @Field(LINK) and pass the independently completed object as its existing target.

Virtual Fields

In addition to fields, setter like methods (i.e. methods with a single parameter) can also be annotated with @Field, making them 'virtual fields'. For virtual fields, the same dsl methods are generated as for actual fields. The name of the methods is the same as the method name.

The annotated method is automatically converted into a Mutator method.

(See: VirtualFieldsDocumentaryTest#'configures a virtual field with a concrete article type'.)

given: // Schema
@DSL class Feed {
    String headline

    @Field
    void article(Article article) {
        headline = article.headline
    }
}

@DSL abstract class Article {
    String headline
}

@DSL class ReleaseNote extends Article {
}

when: // Model
def feed = Feed.Create.With {
    article(ReleaseNote) {
        headline 'KlumAST 4.0 is available'
    }
}

then: // Assertions
assert feed.headline == 'KlumAST 4.0 is available'

Note that, as in the above example, this behaviour, while working with non dsl arguments as well, makes the most sense for actual DSL arguments.

Default Implementation

Using the defaultImpl attribute of the Field annotation, you can specify a default implementation for a field. That way, dsl methods are created as if the field were of the specified type. This is especially useful for interface as field type.

(See: BasicsDefaultImplementationDocumentaryTest#'configures a default implementation through an interface field'.)

given: // Schema
@DSL
class Foo {
    @Field(defaultImpl = BarImpl)
    Bar bar
}

interface Bar {
    String getValue()
}

@DSL
class BarImpl implements Bar {
    String value
} 

Although the field is not of an DSL type, normal DSL methods are created for it:

when: // Model
Foo.Create.With {
    bar(value: "Dieter")
}

This allows models to use interfaces defined elsewhere, by providing a dslified implementation.

The defaultImplementation can also be set on a DSL class, providing the default implementation for all fields of that type:

given: // Schema
@DSL
class Foo {
    Bar bar
}

@DSL(defaultImpl = BarImpl)
interface Bar {
    String getValue()
}

when: // Model
Foo.Create.With {
    bar(value: "Dieter")
}

Usually, default implementation is only used on interfaces or abstract classes, but this is not enforced, since there might be some corner cases where it is useful.

defaultImpl can also be used on collections, maps and virtual fields.

It selects the concrete type used for creation, but does not narrow Builder storage for a field declared as a DSL Object. Such a field, including collection and map values, retains its declared polymorphic Builder type for ownership and other Builder-time relationship handling.

Collections of DSL Objects

Collections of DSL-Objects are created using a nested closure. The name of the (optional) outer closure is the field name, the name of the inner closures the element name (which defaults to field name minus a trailing 's'). The syntax for adding keyed members to a list and to a map is identical.

Inner creators produce child Builders in the same lifecycle as the owning Builder. They return the created Builder so construction can be delegated or composed without materializing an intermediate DSL Object. Completed DSL Objects cannot be inserted into owned composition collections; a collection of existing aggregation targets must be marked LINK.

given: // Schema
@DSL
class Config {
    List<UnKeyed> elements
    List<Keyed> keyedElements
    Map<String, Keyed> mapElements
}

@DSL
class UnKeyed {
    String name
}

@DSL
class Keyed {
    @Owner owner
    @Key String name
    String value
}

when: // Model
Config.Create.With {
    elements { // optional, but provides grouping and additional convenience features
        element {
            name "an element"
        }
        element {
            name "another element"
        }
    }
    keyedElements {
        def keyedBuilder = keyedElement("klaus") {
            value "a Value"
        }
        keyedBuilder.value "final Value"
    }
    mapElements {
        mapElement("dieter") {
            value "another"
        }
    }
}

when: // Alternative flat Model syntax
Config.Create.With {
    element {
        name "an element"
    }
    element {
        name "another element"
    }
    keyedElement("klaus") {
        value "a Value"
    }
    mapElement("dieter") {
        value "another"
    }
}

Automatic Key determination for DSL-Map entries

In case of a keyed Map-Element, the key is automatically used as key for the map entry. This can be overridden using @Field.keyMapping, which also allows using unkeyed elements in Maps.

(See: BasicsKeyMappingDocumentaryTest#'uses default and configured keys for DSL map entries'.)

given: // Schema
@DSL class Foo {
    @Field(keyMapping = { it.secondary })
    Map<String, Bar> bars
    @Field(keyMapping = { it.secondary })
    Map<String, TwoBar> twobars
}

@DSL class Bar {
    String secondary
}

@DSL class TwoBar {
    @Key String key
    String secondary
}

when: // Model
def instance = Foo.Create.With {
    bar {
        secondary "blub"
    }
    bar {
        secondary "bli"
    }
    twobar("boink") {
        secondary "blub"
    }
    twobar("bunk") {
        secondary "bli"
    }
}

then: // Assertions
assert instance.bars.blub
assert instance.bars.bli

assert instance.twobars.blub.key == "boink"
assert instance.twobars.bli.key == "bunk"

Polymorphic collection members

As with Polymorphic DSL members, members of collections can also be of subclasses of the declared types. The same mechanisms for single members can be used for collections, too (with the same caveats as above).

Also, a more powerful approach is available using the Alternatives Syntax.

On collections

Collection declarations must use one of the snapshot-safe supported types: List, Set, SortedSet/NavigableSet, Map, SortedMap/NavigableMap, or EnumSet. Every custom Collection interface or class, and every other concrete Collection type, is rejected during schema compilation.

Builders keep mutable construction collections. Materialization publishes independent read-only snapshots, so neither a Builder collection nor an input collection can mutate the completed model afterward. Sorted collections preserve their comparator. EnumSet getters return defensive copies.

Be careful when using a simple Set. Since Klum creates barebone hashcode implementations (constant zero for non-keyed objects, hashCode of key for keyed objects), a (non Sorted)Set of non-Keyed model objects might result in a severe degradation of performance of that Set.

The @Key Annotation

The key annotation is used to designate a special key field, making the annotated class a keyed class. This has the following consequences:

Ownership and @Owner

Owned DSL Object relationships form one single-rooted composition tree. @Owner may annotate multiple fields or methods on an object; matching owner fields are framework-managed backlinks for navigating upward through that tree, not values configured by a Model Writer. LINK relationships add side connections to existing completed objects without changing composition ownership or its root. See Static Models for the same graph boundary in the static-model overview.

For each owned child Builder, the Owner phase establishes every matching owner field when both of these conditions hold (independently for each field):

(See: OwnerRelationshipDocumentaryTest#'assigns each matching owner field for an owned service'.)

given: // Schema
@DSL
class Foo {
    Bar bar
}

@DSL
class Bar {
    @Owner Foo outer
}

when: // Model
def c = Foo.Create.With {
    bar {}
}

then: // Assertions
assert c.bar.outer === c

Owned relationships are constructed as Builders, not completed DSL Objects. The framework establishes their Owner relationships during the Owner phase before materialization; an owner relationship is therefore not an immediate side effect of calling a relationship method and is unavailable during the inner Builder's initial configuration closure.

If configuration needs the owner, move that code to an Owner or later Builder lifecycle method or closure. Owner methods and closures run after owner fields have been assigned.

Because owner is a property of Closure, it is not advisable to name the Owner field (or any other field) actually owner, because it would be overshadowed in configuration closures.

Owner methods

Setter like methods (single parameter methods) can also be annotated with `@Owner. In that case, all matching Owner methods are called if the object is added to another DSL object (i.e. if the Container object ist assignable to the method parameter type). Owner methods are mutator methods and thus moved into the Builder.

(See: OwnerRelationshipDocumentaryTest#'derives service metadata in an owner method'.)

given: // Schema
@DSL
class Deployment {
    String name
    Service service
}

@DSL
class Service {
    String deploymentName

    @Owner
    void recordDeployment(Deployment deployment) {
        deploymentName = deployment.name
    }
}

when: // Model
def deployment = Deployment.Create.With {
    name 'catalog'
    service {}
}

then: // Assertions
assert deployment.service.deploymentName == 'catalog'

Transitive owners

With the field transitive of the @Owner annotation, the annotated field will be set to the first matching instance in the owner chain (for owner fields and owner methods).

(See: OwnerRelationshipDocumentaryTest#'finds the first matching transitive owner in a deployment path'.)

given: // Schema
@DSL class Parent {
    Child child
    String name
}

@DSL class Child {
    @Owner Parent parent
    GrandChild child
    String name
}

@DSL class GrandChild {
    @Owner Child parent
    @Owner(transitive = true) Parent grandParent
    String name
}

when: // Model
def instance = Parent.Create.With {
    name "Klaus"
    child {
        name "Child Level 1"
        child {
            name "Child Level 2"
        }
    }
}

then: // Assertions
assert instance.child.child.grandParent.is(instance)

Transitive owner fields are ignored when determining the owner hierarchy, i.e. they are not considered actual parent objects.

Root owners

With the field root of the @Owner annotation, the annotated field will be set to the root object of the model (if the type matches). This is useful if an object needs to access the root object, but has not direct backlink chain to it.

This can most conveniently be done using a common baseclass for interested objects:

(See: OwnerRelationshipDocumentaryTest#'makes the root deployment available without a direct owner path'.)

given: // Schema
@DSL
abstract class ModelElement {
    @Owner(root = true)
    MyModel root
}

@DSL
class MyModel {
    SomeElement someElement
}

@DSL
class SomeElement extends ModelElement {
}

@DSL
class AnotherElement extends ModelElement {
}

when: // Model
def model = MyModel.Create.With {
    someElement {}
}

then: // Assertions
assert model.someElement.root.is(model)

Owner converters

Owner converter can be used to convert the owner object to another type before setting the field. In that case, the parameter of the converter closure is used whe determining whether the potential owner object matches (instead of the field type or the method parameter).

(See: OwnerRelationshipDocumentaryTest#'converts an owner into readable service metadata'.)

given: // Schema
@DSL
class Parent {
    Child child
    String name
}

@DSL
class Child {
    @Owner Parent parent
    @Owner(converter = { Parent parent -> parent.name }) 
    String parentName
    
    String name
    String upperCaseParentName
    
    @Owner(converter = { Parent parent -> parent.name.toUpperCase() })
    void setUCParentName(String name) {
        upperCaseParentName = name.toUpperCase()
    }
}

when: // Model
def instance = Parent.Create.With {
    name "Klaus"
    child {
        name "Child"
    }
}

then: // Assertions
assert instance.child.parent.is(instance)
assert instance.child.parentName == "Klaus"
assert instance.child.upperCaseParentName == "KLAUS"

Converting owner fields are ignored when determining the owner hierarchy, i.e. they are not considered actual parent objects.

Field Types

The @Field annotation has a value of type FieldType where special handling of the field can be configured. It currently supports the following values:

PROTECTED

Fields marked as PROTECTED are not externally writable, all dsl methods as well as the dsl methods are created as protected. This essentially means that they cannot be changed directly by a user of the DSL. They can only be changed via custom mutator (or lifecycle) methods or other setters.

BUILDER

BUILDER fields exist only on the generated Builder. Their DSL methods are public, but no corresponding field or getter is generated on the completed DSL Object. They are intended for construction-only state consumed by a later Builder phase.

TRANSIENT

TRANSIENT fields are similar in that they don't get dsl methods either. However, in contrast to all other fields, they retain a public setter in the completed model, taking them effectively out of the Static Models concept. They can be used to add transient data that is not part of the model itself. Transient fields are ignored when checking for equality.

IGNORED

IGNORED fields get not DSL accessors at all. Their setters are still moved to the Builder. As with PROTECTED this means that these fields can effectively only be set from inside lifecycle or mutator methods.

LINK fields model aggregation. They accept existing completed DSL Objects through sealed Builder wrappers but do not create or re-own those targets. All non-LINK DSL Object relationships are owned composition and must be created within the owner's Builder lifecycle.

OPTIONAL_LINK accepts either a locally created child Builder as owned composition or an existing completed DSL Object as an aggregation target. @LinkTo selects this mode by default; use @Field(FieldType.LINK) @LinkTo when a relationship must be aggregation-only. See Layer3 for the relationship boundary.

DSL Interfaces

Interfaces can be marked with @DSL. No transformation will be done for these interfaces; however, a field with an annotated interface type gets its dsl methods generated:

(See: BasicsDslInterfacesDocumentaryTest#'configures a DSL interface through a concrete implementation'.)

given: // Schema
@DSL class Outer {
    Foo foo
}

@DSL class FooImpl implements Foo {
    String value
}

@DSL interface Foo {
    String getValue()
}

when: // Model
Outer.Create.With {
    foo(FooImpl) {
        value "name"
    }
}

Note that the usage of non-DSL classes implementing DSL interfaces might lead to runtime errors when instantiating a model.

Fixed keys

Using the key member of @Field the key of a keyed member can be set by to a fixed or derived value. This removes the key parameter from all creation methods.

key is either a closure on the owning instance or the special class Field.FieldName which uses the name of the member as fixed key.

This is useful if the member is derived from some value of the owner.

For example, consider the following classes:

(See: FixedKeysDocumentaryTest#'derives a keyed database name from its server'.)

given: // Schema
@DSL
class Database {
    @Key String name
    //... more values
}

@DSL
class Server {
    @Key String name
    @Field(key = { name })
    Database database
}

This allows creating the server like:

when: // Model
Server.Create.With("INT") {
    database {  // instead of database("INT") {
       //...
    }
}

The key-member is only valid for single keyed fields.