June 19, 2018

Groovy Goodness: Add Map Constructor With Annotation

Since the early days of Groovy we can create POGO (Plain Old Groovy Objects) classes that will have a constructor with a Map argument. Groovy adds the constructor automatically in the generated class. We can use named arguments to create an instance of a POGO, because of the Map argument constructor. This only works if we don't add our own constructor and the properties are not final. Since Groovy 2.5.0 we can use the @MapConstrutor AST transformation annotation to add a constructor with a Map argument. Using the annotation we can have more options to customize the generated constructor. We can for example let Groovy generate the constructor with Map argument and add our own constructor. Also properties can be final and we can still use a constructor with Map argument.

First we look at the default behaviour in Groovy when we create a POGO:

// Simple POGO.
// Groovy adds Map argument
// constructor to the class.
class Person {
    String name
    String alias
    List<String> likes
}

// Create Person object using
// the Map argument constructor.
// We can use named arguments, 
// with the name of key being
// the property name. Groovy
// converts this to Map.
def mrhaki = 
    new Person(
        alias: 'mrhaki',
        name: 'Hubert Klein Ikkink',
        likes: ['Groovy', 'Gradle'])
        
assert mrhaki.alias == 'mrhaki'
assert mrhaki.name == 'Hubert Klein Ikkink'
assert mrhaki.likes == ['Groovy', 'Gradle']


// Sample class with already
// a constructor. Groovy cannot
// create a Map argument constructor now.
class Student {
    String name
    String alias
    
    Student(String name) {
        this.name = name
    }
}


import static groovy.test.GroovyAssert.shouldFail

// When we try to use named arguments (turns into a Map)
// in the constructor we get an exception.
def exception = shouldFail(GroovyRuntimeException) {
    def student = 
        new Student(
            name: 'Hubert Klein Ikkink', 
            alias: 'mrhaki')
}

assert exception.message.startsWith('failed to invoke constructor: public Student(java.lang.String) with arguments: []')
assert exception.message.endsWith('reason: java.lang.IllegalArgumentException: wrong number of arguments')

Now let's use the @MapConstructor annotation in our next example:

import groovy.transform.MapConstructor

@MapConstructor
class Person {
    final String name // AST transformation supports read-only properties.
    final String alias
    List<String> likes
}

// Create object using the Map argument constructor.
def mrhaki = 
    new Person(
        name: 'Hubert Klein Ikkink', 
        alias: 'mrhaki', 
        likes: ['Groovy', 'Gradle'])
        
assert mrhaki.name == 'Hubert Klein Ikkink'
assert mrhaki.alias == 'mrhaki'
assert mrhaki.likes == ['Groovy', 'Gradle']

// Using the annotation the Map argument
// constructor is added, even though we
// have our own constructor as well.
@MapConstructor
class Student {
    String name
    String alias
    
    Student(String name) {
        this.name = name
    }
}

def student = 
    new Student(
        name: 'Hubert Klein Ikkink', 
        alias: 'mrhaki')
        
assert student.name == 'Hubert Klein Ikkink'
assert student.alias == 'mrhaki'

The AST transformation supports several attributes. We can use the attributes includes and excludes to include or exclude properties that will get a value in the Map argument constructor. In the following example we see how we can use the includes attribute:

import groovy.transform.MapConstructor

@MapConstructor(includes = 'name')
class Person {
    final String name 
    final String alias
    List<String> likes
}

// Create object using the Map argument constructor.
def mrhaki = 
    new Person(
        name: 'Hubert Klein Ikkink', 
        alias: 'mrhaki', 
        likes: ['Groovy', 'Gradle'])
        
assert mrhaki.name == 'Hubert Klein Ikkink'
assert !mrhaki.alias 
assert !mrhaki.likes 

We can add custom code that is executed before or after the generated code by the AST transformation using the attributes pre and post. We assign a Closure to these attributes with the code that needs to be executed.

In the next example we set the pre attribute with code that calculates the alias property value if it is not set via the constructor:

// If alias is set in constructor use it, otherwise
// calculate alias value based on name value.
@MapConstructor(post = { alias = alias ?: name.split().collect { it[0] }.join() })
class Person {
    final String name // AST transformation supports read-only properties.
    final String alias
    List<String> likes
}

// Set alias in constructor.
def mrhaki = 
    new Person(
        name: 'Hubert Klein Ikkink', 
        alias: 'mrhaki', 
        likes: ['Groovy', 'Gradle'])
        
assert mrhaki.name == 'Hubert Klein Ikkink'
assert mrhaki.alias == 'mrhaki'
assert mrhaki.likes == ['Groovy', 'Gradle']

// Don't set alias via constructor.
def hubert = 
    new Person(
        name: 'Hubert A. Klein Ikkink')
        
assert hubert.name == 'Hubert A. Klein Ikkink'
assert hubert.alias == 'HAKI'
assert !hubert.likes

Written with Groovy 2.5.0.