I have been working on my Groovy Validator project. I think I have figured out a way to get the validation annotations to work with immutable objects.
The annotation you need to add for the class is AstImmutableConstructor, which is processed by AstImmutableConstructorTransform. I will reproduce the Groovydoc from AstImmutableConstructor:
BEGIN QUOTE
This is an annotation that can be used to validate fields in immutable objects. It is intended to be used with the Immutable annotation at the class level, although I think it will also work with mutable POGOs as well. The fields can be annotated with the following annotations: DoubleAnnotation, FloatAnnotation, IntAnnotation, LongAnnotation and StringAnnotation. You do not need to run the AnnotationProcessor for this to work.
There is a bit of a bug: It will set String, double, float, int and long fields within the default constraints in the annotations listed in the previous paragraph even if the fields are not annotated. They are pretty broad, but the default for the numbers is to set the minimum equal to 0. So if you have no annotation for an int, and you try to give it a value below 0, it will be set to 0.
Here is an example class:
package info.shelfunit.properties.sample.immutable import info.shelfunit.properties.annotations.AstImmutableConstructor import info.shelfunit.properties.annotations.IntAnnotation import info.shelfunit.properties.annotations.LongAnnotation import info.shelfunit.properties.annotations.StringAnnotation import groovy.transform.Immutable import groovy.transform.ToString @ToString( includeNames = true ) @AstImmutableConstructor @Immutable class ImmutableObject002 { @StringAnnotation( minLength = 5, maxLength = 10 ) String firstString @IntAnnotation( minValue = 10, maxValue = 100 ) int firstInt @LongAnnotation( maxValue = 100L ) long firstLong }
You could set this with a Map like any other immutable object in Groovy, or with the fields, but it will not trigger validation:
def firstImObject = new ImmutableObject002( firstString: "Hello", firstInt: 55, firstLong: 44L )
To get the annotation to actually process, you should send two parameters to the constructor: a Map for the fields, and a boolean for whether or not you want the fields to be validated. If the validation is set to false, the effect is the same as if you simply sent the fields as a Map.
def secondImObject = new ImmutableObject002( [ firstString: "Hi Again", firstInt: 11, firstLong: 22L ], true )
END QUOTE
It’s not as clean as a POGO, but it beats having to write setters and constructors every time you make a POGO.
To get the annotation to actually process work, you should send two parameters to the constructor: a Map for the fields, and a boolean for whether or not you want the fields to be validated. If the validation is set to false, the effect is the same as if you simply sent the fields as a Map.
This annotation is processed in the INSTRUCTION_SELECTION compile phase. This is one phase after CANONICALIZATION, which is when the Immutable annotation is processed.
I went through the usual cycle for ASTTransformations: I wrote a class that implemented the visit method in the ASTTransformation interface, did some magic, called
def ast = new AstBuilder().buildFromString( CompilePhase.INSTRUCTION_SELECTION, false, theString )
in the AstBuilder class, add the nodes I created to my annotated ClassNode, and then we are done.
There is a little bit of voodoo involved. When I call AstBuilder().buildFromString, I am using a String I have created via string interpolation. I was inspired by this in the tests in AstBuilderFromStringTest.groovy. In there, they use a multi-line string to create a class. So I use string interpolation to create a ClassNode with the same name and package name, and I go through each FieldNode and create a new constructor that takes a HashMap and a boolean.
Since the first line in a constructor should be a call to another constructor, I send the HashMap and boolean to a method I create that takes the HashMap and boolean as parameters. In that method, I create a new map. I cycle through all the field nodes, and I compare the values in the map to the arguments given to the validation annotations. If the map value is within the constraints, I add it to the new map. If the map value does not fit the constraints, that key/value pair is not added to the map. Then at the end, the new map is returned to the constructor I created. This new constructor then calls the HashMap constructor that was created by the Immutable annotation.
So if we look at the class at the top of this post in the AST inspector in the Groovy console, we see the constructor I created:
public ImmutableObject002(java.util.LinkedHashMap argMap, boolean validation) { this (ImmutableObject002.createValidatingConstructor(argMap, validation)) }
I also create a method called createValidatingConstructor, which looks like this (once again, from the Groovy console):
public static java.lang.Object createValidatingConstructor(java.util.HashMap argMap, boolean validation) { if (!( validation )) { return argMap } else { java.util.HashMap newMap = [:] java.lang.Object val = val = argMap [ 'firstString'] if (5 <= val?.length() && val?.length() <= 10) { newMap [ 'firstString'] = val } val = argMap [ 'firstInt'] if (10 <= val && val <= 100) { newMap [ 'firstInt'] = val } val = argMap [ 'firstLong'] if (0 <= val && val <= 100) { newMap [ 'firstLong'] = val } return newMap } }
I hope this won’t break in a future version of Groovy. I tried to look through ImmutableASTTransformation.java (which processes the Immutable annotation), and it was not easy to follow.
You’re welcome.
Image from “Psautier de Paris”, a 10th century manuscript housed at the Bibliothèque nationale de France. Source gallica.bnf.fr / BnF; image assumed allowed under Fair Use.