Repository: incubator-groovy Updated Branches: refs/heads/master 14a3a6700 -> 569d68a9b
GROOVY-7353: Groovy should provide a MapConstructor AST transform Project: http://git-wip-us.apache.org/repos/asf/incubator-groovy/repo Commit: http://git-wip-us.apache.org/repos/asf/incubator-groovy/commit/87b6633f Tree: http://git-wip-us.apache.org/repos/asf/incubator-groovy/tree/87b6633f Diff: http://git-wip-us.apache.org/repos/asf/incubator-groovy/diff/87b6633f Branch: refs/heads/master Commit: 87b6633f53a8a7575dba9b0cd27a691cb38e320a Parents: 14a3a67 Author: Paul King <pa...@asert.com.au> Authored: Wed May 20 12:10:46 2015 +1000 Committer: Paul King <pa...@asert.com.au> Committed: Sat May 23 10:07:03 2015 +1000 ---------------------------------------------------------------------- src/main/groovy/transform/MapConstructor.java | 121 ++++++++++ .../groovy/antlr/AntlrParserPlugin.java | 7 +- .../MapConstructorASTTransformation.java | 225 +++++++++++++++++++ .../MapConstructorTransformTest.groovy | 139 ++++++++++++ 4 files changed, 491 insertions(+), 1 deletion(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/incubator-groovy/blob/87b6633f/src/main/groovy/transform/MapConstructor.java ---------------------------------------------------------------------- diff --git a/src/main/groovy/transform/MapConstructor.java b/src/main/groovy/transform/MapConstructor.java new file mode 100644 index 0000000..adcab87 --- /dev/null +++ b/src/main/groovy/transform/MapConstructor.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.transform; + +import org.codehaus.groovy.transform.GroovyASTTransformationClass; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Class annotation used to assist in the creation of map constructors in classes. + * <p> + * It allows you to write classes in this shortened form: + * <pre> + * import groovy.transform.* + * + * {@code @TupleConstructor} + * class Person { + * String first, last + * } + * + * {@code @CompileStatic // optional + * {@code @ToString(includeSuperProperties=true)} + * {@code @MapConstructor}(pre={ super(args?.first, args?.last); args = args ?: [:] }, post = { first = first?.toUpperCase() }) + * class Author extends Person { + * String bookName + * } + * + * assert new Author(first: 'Dierk', last: 'Koenig', bookName: 'ReGinA').toString() == 'Author(ReGinA, DIERK, Koenig)' + * assert new Author().toString() == 'Author(null, null, null)' + * </pre> + * The {@code @MapConstructor} annotation instructs the compiler to execute an + * AST transformation which adds the necessary constructor method to your class. + * <p> + * A map constructor is created which sets properties, and optionally fields and + * super properties if the property/field name is a key within the map. + * <p> + * For the above example, the generated constructor will be something like: + * <pre> + * public Author(java.util.Map args) { + * super(args?.first, args?.last) + * args = args ? args : [:] + * if (args.containsKey('bookName')) { + * this.bookName = args['bookName'] + * } + * first = first?.toUpperCase() + * } + * </pre> + * + * @since 2.5.0 + */ +@java.lang.annotation.Documented +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.TYPE}) +@GroovyASTTransformationClass("org.codehaus.groovy.transform.MapConstructorASTTransformation") +public @interface MapConstructor { + /** + * List of field and/or property names to exclude from the constructor. + * Must not be used if 'includes' is used. For convenience, a String with comma separated names + * can be used in addition to an array (using Groovy's literal list notation) of String values. + */ + String[] excludes() default {}; + + /** + * List of field and/or property names to include within the constructor. + * Must not be used if 'excludes' is used. For convenience, a String with comma separated names + * can be used in addition to an array (using Groovy's literal list notation) of String values. + */ + String[] includes() default {}; + + /** + * Include fields in the constructor. + */ + boolean includeFields() default false; + + /** + * Include properties in the constructor. + */ + boolean includeProperties() default true; + + /** + * Include properties from super classes in the constructor. + */ + boolean includeSuperProperties() default false; + + /** + * By default, properties are set directly using their respective field. + * By setting {@code useSetters=true} then a writable property will be set using its setter. + * If turning on this flag we recommend that setters that might be called are + * made null-safe wrt the parameter. + */ + boolean useSetters() default false; + + /** + * A Closure containing statements which will be prepended to the generated constructor. The first statement within the Closure may be "super(someArgs)" in which case the no-arg super constructor won't be called. + */ + Class pre(); + + /** + * A Closure containing statements which will be appended to the end of the generated constructor. Useful for validation steps or tweaking the populated fields/properties. + */ + Class post(); +} http://git-wip-us.apache.org/repos/asf/incubator-groovy/blob/87b6633f/src/main/org/codehaus/groovy/antlr/AntlrParserPlugin.java ---------------------------------------------------------------------- diff --git a/src/main/org/codehaus/groovy/antlr/AntlrParserPlugin.java b/src/main/org/codehaus/groovy/antlr/AntlrParserPlugin.java index 0527e16..d225ee6 100644 --- a/src/main/org/codehaus/groovy/antlr/AntlrParserPlugin.java +++ b/src/main/org/codehaus/groovy/antlr/AntlrParserPlugin.java @@ -104,6 +104,7 @@ public class AntlrParserPlugin extends ASTHelper implements ParserPlugin, Groovy private int innerClassCounter = 1; private boolean enumConstantBeingDef = false; private boolean forStatementBeingDef = false; + private boolean annotationBeingDef = false; private boolean firstParamIsVarArg = false; private boolean firstParam = false; @@ -1226,6 +1227,7 @@ public class AntlrParserPlugin extends ASTHelper implements ParserPlugin, Groovy } protected AnnotationNode annotation(AST annotationNode) { + annotationBeingDef = true; AST node = annotationNode.getFirstChild(); String name = qualifiedName(node); AnnotationNode annotatedNode = new AnnotationNode(ClassHelper.make(name)); @@ -1244,6 +1246,7 @@ public class AntlrParserPlugin extends ASTHelper implements ParserPlugin, Groovy break; } } + annotationBeingDef = false; return annotatedNode; } @@ -2490,7 +2493,9 @@ public class AntlrParserPlugin extends ASTHelper implements ParserPlugin, Groovy // if node text is found to be "super"/"this" when a method call is being processed, it is a // call like this(..)/super(..) after the first statement, which shouldn't be allowed. GROOVY-2836 if (selector.getText().equals("this") || selector.getText().equals("super")) { - throw new ASTRuntimeException(elist, "Constructor call must be the first statement in a constructor."); + if (!(annotationBeingDef && selector.getText().equals("super"))) { + throw new ASTRuntimeException(elist, "Constructor call must be the first statement in a constructor."); + } } Expression arguments = arguments(elist); http://git-wip-us.apache.org/repos/asf/incubator-groovy/blob/87b6633f/src/main/org/codehaus/groovy/transform/MapConstructorASTTransformation.java ---------------------------------------------------------------------- diff --git a/src/main/org/codehaus/groovy/transform/MapConstructorASTTransformation.java b/src/main/org/codehaus/groovy/transform/MapConstructorASTTransformation.java new file mode 100644 index 0000000..ecbbd9c --- /dev/null +++ b/src/main/org/codehaus/groovy/transform/MapConstructorASTTransformation.java @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.transform; + +import groovy.transform.MapConstructor; +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.AnnotatedNode; +import org.codehaus.groovy.ast.AnnotationNode; +import org.codehaus.groovy.ast.ClassCodeExpressionTransformer; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.ConstructorNode; +import org.codehaus.groovy.ast.DynamicVariable; +import org.codehaus.groovy.ast.FieldNode; +import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.ArgumentListExpression; +import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.ConstructorCallExpression; +import org.codehaus.groovy.ast.expr.Expression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; +import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.EmptyStatement; +import org.codehaus.groovy.ast.stmt.ExpressionStatement; +import org.codehaus.groovy.ast.stmt.Statement; +import org.codehaus.groovy.classgen.Verifier; +import org.codehaus.groovy.control.CompilePhase; +import org.codehaus.groovy.control.SourceUnit; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.codehaus.groovy.ast.ClassHelper.make; +import static org.codehaus.groovy.ast.ClassHelper.makeWithoutCaching; +import static org.codehaus.groovy.ast.tools.GeneralUtils.args; +import static org.codehaus.groovy.ast.tools.GeneralUtils.assignS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.constX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.getInstanceNonPropertyFields; +import static org.codehaus.groovy.ast.tools.GeneralUtils.getInstancePropertyFields; +import static org.codehaus.groovy.ast.tools.GeneralUtils.getSuperPropertyFields; +import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS; +import static org.codehaus.groovy.ast.tools.GeneralUtils.param; +import static org.codehaus.groovy.ast.tools.GeneralUtils.params; +import static org.codehaus.groovy.ast.tools.GeneralUtils.propX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt; +import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; + +/** + * Handles generation of code for the @MapConstructor annotation. + */ +@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) +public class MapConstructorASTTransformation extends AbstractASTTransformation { + + static final Class MY_CLASS = MapConstructor.class; + static final ClassNode MY_TYPE = make(MY_CLASS); + static final String MY_TYPE_NAME = "@" + MY_TYPE.getNameWithoutPackage(); + private static final ClassNode MAP_TYPE = makeWithoutCaching(Map.class, false); +// private static final ClassNode CHECK_METHOD_TYPE = make(ImmutableASTTransformation.class); + + public void visit(ASTNode[] nodes, SourceUnit source) { + init(nodes, source); + AnnotatedNode parent = (AnnotatedNode) nodes[1]; + AnnotationNode anno = (AnnotationNode) nodes[0]; + if (!MY_TYPE.equals(anno.getClassNode())) return; + + if (parent instanceof ClassNode) { + ClassNode cNode = (ClassNode) parent; + if (!checkNotInterface(cNode, MY_TYPE_NAME)) return; + boolean includeFields = memberHasValue(anno, "includeFields", true); + boolean includeProperties = !memberHasValue(anno, "includeProperties", false); + boolean includeSuperProperties = memberHasValue(anno, "includeSuperProperties", true); + boolean useSetters = memberHasValue(anno, "useSetters", true); + List<String> excludes = getMemberList(anno, "excludes"); + List<String> includes = getMemberList(anno, "includes"); + if (hasAnnotation(cNode, CanonicalASTTransformation.MY_TYPE)) { + AnnotationNode canonical = cNode.getAnnotations(CanonicalASTTransformation.MY_TYPE).get(0); + if (excludes == null || excludes.isEmpty()) excludes = getMemberList(canonical, "excludes"); + if (includes == null || includes.isEmpty()) includes = getMemberList(canonical, "includes"); + } + if (!checkIncludeExclude(anno, excludes, includes, MY_TYPE_NAME)) return; + // if @Immutable is found, let it pick up options and do work so we'll skip + if (hasAnnotation(cNode, ImmutableASTTransformation.MY_TYPE)) return; + + Expression pre = anno.getMember("pre"); + if (pre != null && !(pre instanceof ClosureExpression)) { + addError("Expected closure value for annotation parameter 'pre'. Found " + pre, cNode); + return; + } + Expression post = anno.getMember("post"); + if (post != null && !(post instanceof ClosureExpression)) { + addError("Expected closure value for annotation parameter 'post'. Found " + post, cNode); + return; + } + + createConstructor(cNode, includeFields, includeProperties, includeSuperProperties, useSetters, excludes, includes, (ClosureExpression) pre, (ClosureExpression) post, source); + if (pre != null) { + anno.setMember("pre", new ClosureExpression(new Parameter[0], new EmptyStatement())); + } + if (post != null) { + anno.setMember("post", new ClosureExpression(new Parameter[0], new EmptyStatement())); + } + } + } + + public static void createConstructor(ClassNode cNode, boolean includeFields, boolean includeProperties, boolean includeSuperProperties, boolean useSetters, List<String> excludes, List<String> includes, ClosureExpression pre, ClosureExpression post, SourceUnit source) { + List<ConstructorNode> constructors = cNode.getDeclaredConstructors(); + boolean foundEmpty = constructors.size() == 1 && constructors.get(0).getFirstStatement() == null; + // HACK: JavaStubGenerator could have snuck in a constructor we don't want + if (foundEmpty) constructors.remove(0); + + List<FieldNode> superList = new ArrayList<FieldNode>(); + if (includeSuperProperties) { + superList.addAll(getSuperPropertyFields(cNode.getSuperClass())); + } + + List<FieldNode> list = new ArrayList<FieldNode>(); + if (includeProperties) { + list.addAll(getInstancePropertyFields(cNode)); + } + if (includeFields) { + list.addAll(getInstanceNonPropertyFields(cNode)); + } + + Parameter map = param(MAP_TYPE, "args"); + final BlockStatement body = new BlockStatement(); + ClassCodeExpressionTransformer transformer = makeTransformer(); + if (pre != null) { + ClosureExpression transformed = (ClosureExpression) transformer.transform(pre); + copyPreStatements(transformed, body); + } + for (FieldNode fNode : superList) { + String name = fNode.getName(); + if (shouldSkip(name, excludes, includes)) continue; + assignField(useSetters, map, body, name); + } + for (FieldNode fNode : list) { + String name = fNode.getName(); + if (shouldSkip(name, excludes, includes)) continue; + assignField(useSetters, map, body, name); + } + if (post != null) { + ClosureExpression transformed = (ClosureExpression) transformer.transform(post); + body.addStatement(transformed.getCode()); + } + cNode.addConstructor(new ConstructorNode(ACC_PUBLIC, params(map), ClassNode.EMPTY_ARRAY, body)); + } + + private static void assignField(boolean useSetters, Parameter map, BlockStatement body, String name) { + ArgumentListExpression nameArg = args(constX(name)); + body.addStatement(ifS(callX(varX(map), "containsKey", nameArg), useSetters ? + stmt(callThisX(getSetterName(name), callX(varX(map), "get", nameArg))) : + assignS(propX(varX("this"), name), callX(varX(map), "get", nameArg)))); + } + + private static String getSetterName(String name) { + return "set" + Verifier.capitalize(name); + } + + private static ClassCodeExpressionTransformer makeTransformer() { + return new ClassCodeExpressionTransformer() { + @Override + public Expression transform(Expression exp) { + if (exp instanceof ClosureExpression) { + ClosureExpression ce = (ClosureExpression) exp; + ce.getCode().visit(this); + } else if (exp instanceof VariableExpression) { + VariableExpression ve = (VariableExpression) exp; + if (ve.getName().equals("args") && ve.getAccessedVariable() instanceof DynamicVariable) { + VariableExpression newVe = new VariableExpression(new Parameter(MAP_TYPE, "args")); + newVe.setSourcePosition(ve); + return newVe; + } + } + return exp.transformExpression(this); + } + + @Override + protected SourceUnit getSourceUnit() { + return null; + } + }; + } + + private static void copyPreStatements(ClosureExpression pre, BlockStatement body) { + Statement preCode = pre.getCode(); + if (preCode instanceof BlockStatement) { + BlockStatement block = (BlockStatement) preCode; + List<Statement> statements = block.getStatements(); + for (int i = 0; i < statements.size(); i++) { + Statement statement = statements.get(i); + if (i == 0 && statement instanceof ExpressionStatement) { + ExpressionStatement es = (ExpressionStatement) statement; + Expression preExp = es.getExpression(); + if (preExp instanceof MethodCallExpression) { + MethodCallExpression mce = (MethodCallExpression) preExp; + String name = mce.getMethodAsString(); + if ("super".equals(name)) { + es.setExpression(new ConstructorCallExpression(ClassNode.SUPER, mce.getArguments())); + } + } + } + body.addStatement(statement); + } + } + } + +} http://git-wip-us.apache.org/repos/asf/incubator-groovy/blob/87b6633f/src/test/org/codehaus/groovy/transform/MapConstructorTransformTest.groovy ---------------------------------------------------------------------- diff --git a/src/test/org/codehaus/groovy/transform/MapConstructorTransformTest.groovy b/src/test/org/codehaus/groovy/transform/MapConstructorTransformTest.groovy new file mode 100644 index 0000000..22cd930 --- /dev/null +++ b/src/test/org/codehaus/groovy/transform/MapConstructorTransformTest.groovy @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.codehaus.groovy.transform + +class MapConstructorTransformTest extends GroovyTestCase { + void testMapConstructorWithFinalFields() { + assertScript ''' + import groovy.transform.* + + @ToString + @MapConstructor + class Person { + final String first, last + } + + assert new Person(first: 'Dierk', last: 'Koenig').toString() == 'Person(Dierk, Koenig)' + ''' + } + + void testMapConstructorWithSetters() { + assertScript ''' + import groovy.transform.* + + @ToString + @MapConstructor(useSetters=true) + class Person { + String first, last + void setFirst(String first) { + this.first = first?.toUpperCase() + } + } + + assert new Person(first: 'Dierk', last: 'Koenig').toString() == 'Person(DIERK, Koenig)' + ''' + } + + void testMapConstructorWithIncludesAndExcludes() { + assertScript ''' + import groovy.transform.* + + @ToString(includes='first') + @MapConstructor(includes='first') + class Person { + String first, last + } + + assert new Person(first: 'Dierk').toString() == 'Person(Dierk)' + ''' + assertScript ''' + import groovy.transform.* + + @ToString @MapConstructor(includes='first') + class Person { + String first, last + } + + assert new Person(first: 'Dierk', last: 'Koenig').toString() == 'Person(Dierk, null)' + ''' + assertScript ''' + import groovy.transform.* + + @ToString @MapConstructor(excludes='last') + class Person { + String first, last + } + + assert new Person(first: 'Dierk', last: 'Koenig').toString() == 'Person(Dierk, null)' + ''' + } + + void testMapConstructorWithPost() { + def msg = shouldFail(MissingPropertyException, ''' + import groovy.transform.* + import org.codehaus.groovy.transform.ImmutableASTTransformation + + @ToString + @MapConstructor(post={ ImmutableASTTransformation.checkPropNames(this, args) }) + class Person { + String first, last + } + + new Person(last: 'Koenig', nickname: 'mittie') + ''') + assert msg.contains('No such property: nickname for class: Person') + } + + void testMapConstructorWithPostAndFields() { + assertScript ''' + import groovy.transform.* + + @ToString(includeFields=true, includeNames=true) + @MapConstructor(includeFields=true, post={ full = "$first $last" }) + class Person { + final String first, last + private final String full + } + + assert new Person(first: 'Dierk', last: 'Koenig').toString() == + 'Person(first:Dierk, last:Koenig, full:Dierk Koenig)' + ''' + } + + void testMapConstructorWithPreAndPost() { + assertScript ''' + import groovy.transform.* + + @TupleConstructor + class Person { + String first, last + } + + @CompileStatic // optional + @ToString(includeSuperProperties=true) + @MapConstructor(pre={ super(args?.first, args?.last); args = args ?: [:] }, post = { first = first?.toUpperCase() }) + class Author extends Person { + String bookName + } + + assert new Author(first: 'Dierk', last: 'Koenig', bookName: 'ReGinA').toString() == 'Author(ReGinA, DIERK, Koenig)' + assert new Author().toString() == 'Author(null, null, null)' + ''' + } +}